Skip to main content
stralos-feature-estimate is the Module Federation remote that owns the estimate workspace, project documents, variations, and subcontractor portal routes. It is registered in the shell as the estimate remote.

Routes

Routes are registered in src/routes.ts as a RouteDefinition[]. The Documents and Variations paths sit outside the remote’s primary base path and are declared as entries in the estimate descriptor’s paths array in the stralos-core seed. The shell’s generic mount builder produces them; no bespoke shell constants are required. See Micro-frontends — Split-mount nav pattern.

Estimate rail active marking

Every estimate destination in the project workspace drawer owns its descendants for active-marking purposes, except the Project overview entry. The shell’s estimateNavItem helper sets end: false (via ownsDescendants: true) for all estimate builder sections and tabs, and end: true for the overview only. This means that when the user opens a route that is a descendant of a nav item — for example the calculator workbench (/builder/calculators/:calculatorId), the rates re-apply review (/builder/rates/re-apply), or a tender submission sub-section (/tender-submission/margins) — the parent nav item (Calculators, Project rates, or Tender submission) remains highlighted. Before this fix all destinations used end: true, so those descendant pages lit no rail item.

Work Items import drawer

WorkItemsImportDrawer (src/WorkItemsImportDrawer.tsx) is opened from the Import button in the work-items toolbar. It hosts two sources: file and project, toggled by a source selector. The FileImportFlow component handles the file path; the project path is bespoke to this drawer. Project import flow:
  1. listAllProjects fetches every project the session can see. The current project is excluded.
  2. Selecting a source project calls previewProjectImport (POST /api/estimate/estimates/:estimateId/work-items/import-project/preview, src/api.projectImport.ts). The preview returns workItems, sections, calculators, currentWorkItems, overrides, tree, and warnings. tree drives the Choose sections and lines picker.
  3. The estimator chooses What to import (scope: the whole schedule, or a pick), then configures mode ("append" | "replace"), includeDetails, calculators ("copy" | "rates_only"), pricing ("latest" | "source"), and which overrides to keep.
  4. Selecting Import estimate items calls importProjectWorkItems (POST /api/estimate/estimates/:estimateId/work-items/import-project). The request body is a ProjectImportChoices object including requestId (UUID) and previewToken (returned by the preview step).
  5. The result (ProjectImportResult) carries workItems, sections, calculators, removedWorkItems, refreshedRates, and warnings.
Idempotency guard: on submit, the choices and preview are written to sessionStorage under stralos.projectImport.<estimateId>. On mount, the drawer reads this key. If a pending record is found, it enters an uncertain state, disables mode/source switching, and shows Retry same import to resubmit without re-importing. A confirmed response, a 4xx refusal, or a deliberate close clears the key. This prevents double-import after a network interruption. A ConfirmDialog fires before a replace-mode import when the current estimate has work items (preview.currentWorkItems > 0). Choosing sections and lines (src/ProjectImportPicker.tsx, src/projectImportSelection.ts): a pick always sends mode: "append" and a selection built by buildImportSelection (sectionIds, workItemIds, linesTarget, sectionPlacements). The mode and Calculators selects are hidden; when the chosen lines use calculators, ProjectImportQuestions asks whether to bring them, and the answer sets calculators to "copy" or "rates_only". It also asks where each same-level title clash goes and where lines without their section land; importProblems keeps Import estimate items disabled until every question is answered. A 409 project_import_section_exists adds the server’s sectionIds to askedByServer so those sections are asked about, and the estimator resends. Whole-schedule title clash: a 422 project_import_section_title_taken on a whole-schedule import stores the clashing sectionIds and shows Choose sections instead. Selecting it switches to a pick with every section ticked, plus every top-level line with no section, and marks the clashing sections as asked.

Per-area file import drawers

FileImportDrawer (src/FileImportDrawer.tsx) wraps FileImportFlow in a SideSheet and is used by three panels for area-scoped document uploads: FileImportFlow (src/FileImportFlow.tsx) handles the upload state: useDocumentUpload is called with { estimateId, targetAreas: [area] } so the upload is immediately routed to the panel’s own area. In-place processing (processesInPlace, Graeme 2026-10-05): takeoff and work_items uploads are processed inside the drawer — nobody is sent to the Documents page. processesInPlace(area) returns true for these two areas. For all other areas, a Review [area label] suggestions button navigates to projectDocumentsReviewPath(projectId, { area }) once all files succeed. For in-place areas, useWatchedDocuments (src/watchUploadedDocuments.ts) polls each uploaded document’s record until the file settles (no longer isDocumentInflight). The flow passes onUploaded (a host callback to refresh the estimate’s documents) instead of onImported (the navigate-to-review callback). inPlacePhase(area, upload) (exported from FileImportFlow) maps the combination of area, job file, and latest document record to one of four phases used to render each row in the drawer: For a non-PDF takeoff upload the attention phase reads immediately from the picked file (isPdfFile(job.file)) — the drawer does not wait for the server response to tell the user the file cannot be drawn on. Work-items suggestions sheet (SuggestionImportSheet, src/SuggestionImportSheet.tsx): once a work_items upload settles, WorkItemsImportDrawer opens this sheet for that document’s suggestions. The sheet is a full-width SideSheet (90vw, ui size 2xl) so the description column is readable at 1280 px+. It lists suggestions in schedule order (orderSuggestions: sections in first-row-number order, rows within sections by item number, unsectioned rows last). Columns: No., Section, Description (editable), Qty (editable), Unit (editable), Kind, Confidence, Page. Ticked rows are imported one at a time in that order (acceptSuggestion then acceptWorkItemSuggestion); unticked rows are dismissed (rejectSuggestion). Edits are overrides passed to buildWorkItemAcceptOverrides. Each document is reviewed at most once — the drawer tracks which document IDs have been reviewed and does not reopen their sheet on subsequent polls. Closing the sheet without importing leaves every suggestion in the pending state; they remain accessible from the project Documents page. File types accepted by these per-area drawers: PDF, PNG, JPEG, TIFF, DOCX, XLSX, CSV. DWG and DXF are rejected client-side with an explanatory hint — drawings go through the project Documents page only (DWG→PDF §5.3). The Work Items panel’s own Import button opens WorkItemsImportDrawer (not FileImportDrawer directly); the file path inside that drawer delegates to FileImportFlow with area="work_items".

Project Rates page (S4)

The Project Rates builder section (/estimate/builder/rates) is served by src/ProjectRatesPage.tsx, which re-exports ProjectRatesPanel from src/projectRates/. It replaced the previous override split view (Active overrides / Regular rates). The deleted components are: the override split view, ProjectRateEditorModal, formatProjectRateOverrideSource, the header’s Re-resolve estimate pricing… button, and deleteProjectRateOverride. S6b retired the legacy project rate reads and the scalar resolver arm for overrides: listProjectRateOverrides, loadAllProjectRateOverrides, and upsertProjectRateOverride have been removed. The pricing engine no longer reads GET /api/rates/project-rate-overrides; all legacy overrides are now priced through their S1b mirror project options. markProjectRateOptions applies the projectOverrideUnitPrice / projectRateOverrideId pair from the mirror option’s own cost and id, so stored component snapshots remain valid without any remap. Scoped rate ownership uses scope / supplierId / locationId throughout the estimate remote: the pricing engine, project rates panel, supplier picker, and quarry supplier analysis all share this shape from @stralos-co-nz/domain 1.5.0. rateScope.ts owns the frontend identity rules and projectRateCreatorSteps.tsx owns the inline supplier and location creation steps. See Rate ownership model and Supplier picker and sheet steps below. src/api.projectRates.ts owns the page’s HTTP calls across the Rates and Estimate services. Rates calls use GET /api/rates/project-rates (paged to exhaustion, with pendingRepriceCount), POST /api/rates/project-rates/replacements, POST /api/rates/project-rates/project-only, PUT or DELETE /api/rates/project-rates/:optionId, POST /api/rates/project-rates/:optionId/promote, and POST /api/rates/project-rates/reprice-pending; project-scoped calls include projectId in the query string or request body. Estimate calls use GET /api/estimate/estimates/:estimateId/work-items/:workItemId/project-rates, POST to the adjacent project-rate-attachments collection, DELETE /api/estimate/project-rate-attachments/:attachmentId, GET /api/estimate/estimates/:estimateId/project-rate-usage, and POST /api/estimate/estimates/:estimateId/work-items/insert-project-rate. Parsers validate server shapes so an additive field does not break the page. The Import rates button is present in the page header whenever an estimateId is available. Clicking it navigates to ${builderSectionPath(projectId, "rates")}/import — that is, /projects/:projectId/estimate/builder/rates/import — which is served by stralos-feature-rates’ RateImportPage. The path stays within the estimate URL namespace so the shell keeps the estimate remote mounted. When the importer finishes and returns, it passes a projectRateImport entry in React Router’s location state. ProjectRatesPanel reads that state on mount and after each navigation, applies the returned reprice result via noteRemovedCompanions and onRateExceptionDecision, requests a badge refresh, then replaces the current history entry with the state removed so a forward/back cycle does not re-apply the receipt. useProjectRatesData reads all project rates plus the estimate’s usage. Once per read it finishes any pending automatic re-price (PRD F.4); a still-failing delivery is reported and not retried. URL state (search, filters, chips, view) is held in prefixed query parameters via useProjectRatesUrlState, replacing history rather than pushing. The work item panel (WorkItemProjectRates.tsx) is mounted from WorkItemDetailPane via WorkItemsPricingPanel. It shares useProjectRatesData and the same re-price logic as the page. FROZEN_BANNER lives in projectRateDialogText.ts (a pure text module) so the pane does not import the full page. Rate exception badge refresh is coordinated through rateExceptionBadgeRefresh.ts: a write fires requestRateExceptionBadgeRefresh(), the publisher re-reads immediately rather than waiting for its 30 s poll. A request made while a read is in flight queues one more read behind it. Shared count store (rateExceptionCountStore.ts): RateExceptionBadgePublisher and the estimate attention strips (which mount earlier, in the commit that resolves the estimate) each previously fired their own GET rate-exceptions/count?shape=2 — two identical reads on every estimate route load. They now share a single module-level store. A reader that arrives while a read is in flight joins it. A reader that arrives within 30 seconds of a settled read takes that answer without a new request. The badge’s first read for a route also checks pendingStripRead so it can join the strip’s earlier in-flight read; subsequent reads (poll, focus, post-write refresh) always start their own request. The store is module-level inside the estimate remote, so test suites that reuse an estimate id must call resetRateExceptionCountStoreForTests() between tests to prevent cross-test cache pollution.

Rate exceptions panel (RateExceptionsPanel)

RateExceptionsPanel (src/RateExceptionsPanel.tsx) renders the list of unresolved rate exceptions on the Rates tab. Unit symbol formatting (rateExceptionSentence): the server sends each exception’s sentence with raw domain unit codes such as square_metre, m2, or m3. rateExceptionSentence rewrites those tokens to display symbols (m², m³, t, m) before rendering. A unit the formatter does not recognise is left as-is, so the sentence never shows a raw API key to the estimator. Action ordering (orderedExceptionActions): each exception card may carry both fixing actions (for example Supply missing input) and dismissing actions (for example Accept). orderedExceptionActions moves any fixing actions to the front of the list so the first button is always the one that resolves the underlying issue. When every action is dismissing, the server’s order is preserved. The first action receives colorPalette="teal" variant="solid" (primary); subsequent actions receive colorPalette="gray" variant="outline" (secondary). The hierarchy is determined by position, not by the token name, so it follows orderedExceptionActions’ output directly. Read-only mode (readOnlyReason): ProjectRatesPanel passes its read-only reason (from projectRatesReadOnlyReason in projectRatesLock.ts) to RateExceptionsPanel. When set, every decision button and every grouped ”… for all N” button renders disabled inside a DisabledReason tooltip. isExceptionDecision(action) treats the tokens keep_pin, accept, apply_rule, follow_rule, and clear_band_override as decisions; the others (choose_option, widen_policy, supply_input) only open a picker or a price and stay active. If a decision races an estimate freeze and the server returns 409 estimate_frozen, frozenRefusalText surfaces the server’s message instead of the stale-exception fallback.

Rate ownership model (rateScope.ts)

src/rateScope.ts defines the ownership identity for a project rate. A RateAuthority carries a scope ("organisation" or "supplier"), supplierId (non-null for supplier-scoped options), locationId, and resolved supplier and location objects. This triple — scope, supplierId, locationId — replaces the retired rateSourceId / rateSourceLocationId pair as the canonical ownership key throughout the estimate remote (domain @stralos-co-nz/domain 1.5.0). rateOwnerName(option) resolves the display name for any option: "Your organisation" (falling back to the current org name from localStorage) for organisation-scoped options, or the supplier’s own name for supplier-scoped ones. It never appends "(Internal)" — the old source picker appended that label to the internal-source row, producing labels such as "Internal (Internal)". validRateAuthority(v, active?) is the runtime type guard. It enforces the constraint that supplierId is non-null exactly when scope === "supplier", that a supplier object is present when supplierId is set, and that location.partyId matches supplierId.

Location owner catalogue (listRateLocationOwners)

listRateLocationOwners() in src/api.global.ts replaces the retired rate_sources / rate_source_locations reads as the data source for the owner and location picker. It issues two concurrent reads — all supplier companies and all locations (owner=all) — then partitions the locations by partyId:
  • A place with partyId: null belongs to the organisation (organisationLocations).
  • A place whose partyId matches a company in the supplier register is filed under that company.
  • A place whose partyId names a company the supplier register does not list is dropped, not promoted to the organisation’s own sites.
The result is a RateLocationOwners value: { organisationLocations, suppliers }. The organisation’s display name is the shell’s to supply at render time; it is never stored in this result. The single GET /api/global/locations?owner=all replaces the former two-pass owner=ours plus owner=theirs strategy. Two passes over the same table could observe different instants; a location reassigned between them would appear twice or not at all.

Supplier picker and sheet steps

ProjectRateSupplierPicker (src/projectRates/ProjectRateSupplierPicker.tsx) is a hook (not a component). It returns content, locationName, steps, saving, and stepOpen. The steps array feeds ProjectRateSheet’s own steps prop so that creating a supplier or location is a step inside the rate sheet — not a modal stacked above it. Returning the spec on every render rather than capturing it at open time keeps the step’s title, footer, and back button live. The picker uses RateLocationPicker from @stralos-co-nz/ui-reference-data (0.3.0 or later; it was in @stralos-co-nz/ui from 0.63.0 until 0.73.0 removed it) to let the estimator choose a scope (organisation or supplier) and optionally a location. Selected state is a RateLocationPickerSelection: { scope, supplierId, locationId }. The picker follows three ownership rules:
  • ”+ Create new supplier” is a link beneath the owner field, not a selectable row inside it. As a row it was a reserved string token, and a keyboard walk through companies could land on it and open a step nobody requested.
  • “Create our own source” is gone. Organisation scope is a scope on the option row, not a company record. The admin-permission gate that previously guarded this option now gates whether the organisation row is offered in the picker at all.
  • Companies display as their own name. The old source picker appended "(Internal)" to the organisation row, which produced "Internal (Internal)" when the organisation was named “Internal”.
projectRateCreatorSteps.tsx (src/projectRates/projectRateCreatorSteps.tsx) contains the step body forms: SupplierStepForm and LocationStepForm. These replace the SupplierEditorModal and LocationEditorModal dialogs in the project-rate creation flow. @stralos-co-nz/maps-ui 0.10.0 removed LocationEditorModal. createProjectRateSupplier(name) in src/projectRates/projectRateReferences.ts creates a company via POST /api/global/parties and returns a RateSourceDefinition. createProjectRateLocation(partyId, location) writes the location to either POST /api/global/parties/:partyId/locations (supplier-owned) or POST /api/global/locations (organisation-owned). partyId carries three states: undefined means nothing chosen, null is the deliberate choice of the organisation, and a string is the supplier’s company id. The route is determined from partyId, never from a field in the body — the API ignores a body-carried owner on POST /locations. The “this supplier has no company record” guard that previously existed in createProjectRateLocation has been removed. It guarded against rate_sources rows that carried no party_id; those rows no longer exist. LocationEditorValues is typed from @stralos-co-nz/maps-ui (0.5.0 or later; @stralos-co-nz/ui removes its copy in 0.73.0). Its partyId field (new in ui 0.63.0) replaced rateSourceId as the ownership field; maps-ui 0.10.0 removed rateSourceId. scripts/check-package-compat.mjs enforces this at lockfile level: it refuses any lockfile that resolves @stralos-co-nz/ui >=0.57.0 together with @stralos-co-nz/maps-ui <0.3.0. The check runs before pnpm install in CI. To add a new known-incompatible pair, add an entry to the INCOMPATIBLE array in that file across every repo that carries it.

Rate option conversion factor (rateOptionFactor.ts)

src/projectRates/rateOptionFactor.ts (formerly quarryDensity.ts) owns the field words, validation, and pre-fill logic for the conversion factor on a project rate option. The field is hidden unless rateOptionConversionKind({ rateGroup, optionUnit }) (from @stralos-co-nz/domain) returns a non-null kind; which kind applies is determined by the rate group and the canonical unit of the option being priced. Two conversion kinds exist:
  • Density (numeratorUnit === "tonne") — applies to quarry-materials options, whatever their unit. The DENSITY_FIELD constant holds the label "Density (t/m³)", helper text, advisory ceiling (5 t/m³), and validation rules. api-rates refuses a quarry option without a density (422 assertOptionDimensions).
  • Stock coverage — applies to options priced per length, roll_length, roll_area, or sheet. stockField(label, denominatorUnit) builds the field spec from STOCK_COVERS, which maps each unit to its coverage noun, measured-unit name, and example. Field labels: "Metres per length", "Metres per roll", "m² per roll", "m² per sheet". An option with no factor cannot price a metre or m² line.
factorFieldFor(rateGroup, optionUnit) is the single lookup entry point. It folds the unit onto its catalogue code before calling rateOptionConversionKind, so a quote import’s "Len" is treated as length. replacedFactor(options, { unit, supplierId, locationId }) computes the pre-fill value for a new price. It searches current active library options at the given unit, preferring: this supplier at the chosen location → this supplier elsewhere → standard condition → cheapest. If no current option carries a factor, the field starts empty and is required. readFactor validates the typed string; valid range is (0, 1 000 000] with at most 4 decimal places (api-rates’ isValidConversionFactor). factorHint produces the DENSITY_TOO_HEAVY_HINT advisory for a density above 5 t/m³. Only the scalar travels to the server. api-rates derives the factor orientation from the same rateOptionConversionKind call and rejects any client-sent orientation field.

Whole-unit stock ordering (src/pricing/quantityRuns.ts)

src/pricing/quantityRuns.ts is the browser’s mirror of stralos-api-estimate’s src/pricing/quantityRuns.ts (PR 363 + PR 365). It must remain byte-for-byte equivalent to that file; a rule change goes there first and is reflected here. wholeUnitStockOrdering is asked before pairAwareConversionFactor on every pricing path. It calls orderedQuantityForPricedUnit from @stralos-co-nz/domain after folding all units onto their catalogue codes via canonicaliseUnit. Three outcomes:
  • A result → price at its orderedQuantity, pass measuredPerWholeUnit and measuredRuns through the ChargeContext, and use conversionFactor: null so the pair-aware logic does not double-apply the factor.
  • { excluded: "conversion_missing" } → option cannot price the line (count against a per-length price, missing or out-of-bounds factor); the option is excluded.
  • null → not whole-unit stock (or already measured in the priced unit); the pair rules apply.
measuredRunsForComponent computes the measuredRuns array for one build-up line. The server stamps quantity_runs on a row when it splits a measured quantity across runs (link resolution, calculator reconcile). A typed quantity, an override, or a NULL column is one run. The function mirrors all pricing arms from api-estimate: whole-item, per-unit (with perUnitBasisRuns for each/number work items), resource-count repetition (runsForResourceCount), and the full activity/daily-production arms added in PR 365.

Calculators page (CalculatorsPanel) and workbench (CalculatorWorkbenchPage)

The estimate remote declares calculators first in BUILDER_SECTIONS, while keeping work-items as the default for /estimate/builder. The shell independently declares the same builder section and renders Calculators immediately before Estimate schedule in its estimate navigation. EstimatePage loads work items when either Calculators or Estimate schedule is active so the Used by pickers work on a direct visit to the Calculators section.

Calculator workbench route (D1)

Trench, pit, and volume calculators open on a full-page CalculatorWorkbenchPage instead of a cover dialog (modal/drawer review D1, 2026-09-16). Two routes sit under the Calculators section on the same EstimatePage instance: CalculatorWorkbenchPage (src/CalculatorWorkbenchPage.tsx) owns the single copy of the save sequence that the former Calculators section and Work Items page each carried: nothing written until Save, one create-or-update, adopt, then one PUT /links. useCalculatorLeaveGuard (src/calculatorLeaveGuard.tsx) intercepts every route leaving the page (useBlocker) and browser reloads (beforeunload), asking Discard changes? when the geometry or assignments are dirty. The guard lets Save’s own navigation through by calling allowLeave before it navigates. Every trigger that opens the workbench — New calculation… on the Calculators section, a row name on the Calculators section, New calculation from the Calculations tab on a work item row or detail pane, and a build-up activity’s Calculator submenu — navigates with the page it came from as the back destination. A cold deep link replaces itself with the Calculators list fallback. The legacy embedded editors keep the modal. CalculatorModalHost accepts a page prop to render either the workbench frame or the legacy modal frame with the same injected catalogues. The calculator client follows the Estimate API contract: copy uses /calculators/:id/copy, delete requires keepRows, and mutations read the calculator plus warning envelopes returned by the server.
  • keepRows is a required query parameter on delete. CalculatorDeleteDialog (src/CalculatorDeleteDialog.tsx) presents two explicit choices, Delete the rows too and Delete, keep the rows, so the estimator always answers the question rather than the client choosing a default. CalculatorsPanel (row action) and CalculatorWorkbenchPage (Remove calculator) share it. The workbench passes onRemove only when editor.projectCalculator is set; for an unsaved draft it passes undefined, so the editor’s own clear runs instead.
  • The client is rewritten against api-estimate’s zod schemas. config is required on create and must be engine-readable; an empty config ({}) returns 422 calculator_config_unreadable. The destination is a flat { workItemId, componentId? } pair, not a discriminated union.
  • rateSummaries, currency, and loadRateOptions have been removed from all three calculator editors. They were passed through a JSX spread and silently ignored; the removal is verified by hand since excess-property checks do not apply to spread variables.
  • normalizeTrenchCalculator now comes from @stralos-co-nz/calculators (the engine package) rather than being defined locally.

D3/R2 — Calculations tab (WorkItemCalculations)

WorkItemCalculations (src/WorkItemCalculations.tsx) is the Calculations tab of the work item detail pane (formerly CalculationsDialog). The row menu, the provenance chip, and the pane’s own provenance links open the tab; New calculation… opens the workbench on a draft defaulted to the item. It replaces two earlier controls — the Add calculation radio menu and the separate Calculation assignments button — with one tab carrying two views:
  • Measurements (default) — lets the estimator price any reading of a stored calculator (total length, saw-cut, layer area, etc.) by a rate item. A reading is a CalculatorMeasurement; selecting a rate triggers PUT /calculators/:id/links (to create the link and receive the generated componentRowId) then updateWorkItemComponentRateLink (to price it). Both steps always run in that order: reversing them prices nothing.
  • Layer destinations — embeds the D67 assignment editor (CalculatorAssignmentsBody from calculatorAssignmentsView.tsx) as a second tab rather than opening a dialog inside a dialog. It loads every estimate calculator, including calculators with no destination on the current work item.
The tab opens on Measurements unless a focusCalculatorId is supplied (from a D69 provenance link), which selects Layer destinations to let the estimator see which calculator drives the row they selected. Only the Layer destinations view has unsaved state (editor.unsaved). The Measurements view writes on its own Save and holds nothing afterwards, so it never arms the dirty-close guard. The write lock (writing) is shared across both views: switching views does not unmount a PUT /links in flight.

D67 — assignment control

CalculatorAssignmentsSheet (src/CalculatorAssignmentsSheet.tsx) is a modal SideSheet lg that opens over the workbench and over the Calculators list. Its body and all logic live in calculatorAssignmentsView.tsx (CalculatorAssignmentsBody, useCalculatorAssignmentsEditor) so that WorkItemCalculations can embed the same editor directly as its Layer destinations view without copying code. The sheet is the standalone frame used from the workbench and Calculators page; the Calculations tab renders the shared body without the sheet. layerCatalogue filters the engine’s rows through nonMeasurementRows so the assignment editor lists layers only. Measurement rows are excluded because they are read-outs, not layers. The bulk-dig rows (pit-excavation and vol-excavation) and their per-layer readings (m:l:pit-excavation:*) are also excluded — isBulkDigLayerId in src/calculatorsApi.ts covers both the row ids and any reading whose scope names them. The overall “Excavation volume” reading is still offered. layerCatalogue uses the same current calculator builders as the server. An unassigned layer is offerable, only the outputs a layer states are offered (D66: absent is not zero), and D67’s two regimes are determined from layerKind. Only excavation layers are partitionable across destinations; reinstatement, backfill, and pipe-run layers are whole-layer exclusive. Each LayerEntry in the catalogue carries a neutralVolume?: boolean flag. It is set for non-bulk reinstatement layers — layers whose volume is a geometric quantity with no compaction physics (e.g. a concrete base or a layer backed by a non-bulk rate item). A neutral-volume layer’s in-situ volume is plain m³, not compacted m³, so the quantity picker must not label it “m³ compacted”. calculatorOutputUnitLabel(key, layerKind, neutralVolume) in src/calculatorsApi.ts produces the material-context label shown beside the quantity picker for each offered output. The label varies by layerKind and the neutralVolume flag: The share input for a partitionable (excavation) layer accepts a percentage or an empty value. An empty share means the whole layer is assigned to that destination. A share of 0 is refused — the register’s database constraint rejects it. This distinction is enforced in parseSharePercent and is preserved through the dialog’s toLinkInputs serialisation. The server is the authority on the allocation total. The client shows an advisory running total as the estimator types, but the save is what refuses. Server refusal messages are rendered verbatim. There is no “you will lose the rate” warning: re-pointing a link moves the row, not the money. Valid destination errors returned by the server: calculator_destination_not_an_activity (component is not an activity container) and calculator_destination_wrong_work_item (component belongs to a different work item).

Calculation views

WorkItemCalculations (src/WorkItemCalculations.tsx) lets a work item use as many calculations as the estimator creates. The type choice lives in the tab’s New calculation… menu, which navigates to the workbench without selecting an existing calculation. The tab carries two views, toggled by a button pair:
  • Measurements (default) — prices a reading from a stored calculation by a rate item. The catalogue is computed in the browser from the stored config by calculatorMeasurements; no server arithmetic is duplicated. A reading the engine cannot state is listed disabled with the engine’s reason (R12). A reading already priced shows which build-up line it feeds; a reading can reach exactly one destination. A reading of 0 warns that the next calculation save will remove it. The write is PUT /calculators/:id/links (whole register plus one link — the route REPLACES) followed by updateWorkItemComponentRateLink on the componentRowId the response carries, exactly as Choose rate does. A selected rate whose canonical unit does not match the reading’s output key dimension shows an inline unit mismatch warning. Whole-unit stock options are exempt: rateOptionConversionKind({ rateGroup: null, optionUnit }) is called with rateGroup: null (stock kinds only); when its numeratorUnit equals the reading’s dimension the warning is suppressed, because the option’s factor will order the correct stock count at pricing time. Quarry density options (numeratorUnit === "tonne") do not cover length or area dimensions and still warn on those readings.
  • Layer destinations — the D67 partition editor, embedded rather than nested in a second dialog (a dialog inside a dialog is not operable). It loads every calculator in the estimate, including calculators with no destination on the current work item.
The two views are mutually exclusive about which rows they list. layerCatalogue excludes measurement rows; measurementCatalogue (new) excludes layer rows. Neither view renders rows from the other. R9: a build-up row whose quantity is derived from a calculation (calculatorId is set) renders its quantity as read-only text — “Set by <calculation> — edit the calculation”. The cell was an editable input whose value the next calculator save silently overwrote. The rate on the same row remains editable and survives re-derivation. The write is serialised: one PUT /calculators/:id/links at a time across both views. Every write is built from the register the last response left, so two concurrent writes cannot overwrite each other from the same stale snapshot. A response is adopted into the layer drafts only when that calculator is clean, so a measurement write cannot discard shares typed while it was in flight. toLinkInputs requires the calculator’s current register as a second parameter and appends measurement links verbatim before writing, so a layer-assignment save cannot delete priced readings. A link naming a layer the engine no longer emits is still dropped; that is a separate rule and stays.

Save-all behavior and warning notices

The Save destinations button sends every calculator with unsaved edits in a single pass (saveAll in calculatorAssignmentsView.tsx). The pass runs as follows:
  1. Every share is checked against the client’s own share arithmetic before anything is sent. A share that is already malformed refuses the whole save; nothing is written.
  2. Each calculator is then attempted independently. A server refusal on one does not stop the others — each PUT /calculators/:id/links is its own call, and a failure naming one calculator leaves the rest to continue.
  3. A partial save never reads as success. The tab shows one alert per refused calculator. Successfully saved calculators re-seed clean; refused ones keep their drafts and remain guarded.
  4. When the server returns warnings — a D67 under-allocation notice, or an engine-level note about geometry — the tab shows them. The embedded work-item tab stays open after every outcome, including a clean save; the standalone side sheet can close after a clean save with no warnings.
describeWarnings formats D67 service warnings; describeEngineWarnings formats the engine’s own CalculatorEngineWarning shapes. Both lists are collected as notices and shown in the AssignmentMessages component alongside any refusals. Noticed warnings do not block subsequent saves; the estimator can press Save destinations again after reviewing them. neutralVolumeLayers in calculatorAssignments.ts identifies layers that use material by volume instead of a bulk-physics conversion, allowing the Used by display to distinguish them. With calculators 1.6.0, pit reinstatement layers moved from the top-level backfill key into layers[]. A migrated pit keeps pit-backfill as the first layer’s id. The helper now reads every normalised layer’s nonBulk value from layers[], so legacy non-bulk backfill is no longer classified as bulk. For the fill-layer-stratum-unspecified warning, describeEngineWarnings now renders the engine-owned FILL_LAYER_STRATUM_UNSPECIFIED_MESSAGE rather than converting the warning kind into raw words. The estimator sees Choose which excavation layer this reinstatement layer comes from. AssignmentMessages is shared between CalculatorAssignmentsSheet and the embedded Layer destinations view inside WorkItemCalculations, so refusals and notices render identically in both frames. calculatorProvenance.tsx provides a shared renderer used by three surfaces: the work-items desktop row, the work-items mobile card (which had no calculator affordance before this), and the detail pane’s build-up lines. CalculatorProvenanceLink renders the calculator’s name as a visible link. The name is read live from the server — calculatorName can be null while calculatorId is not (a renamed calculator retains its id). A missing name falls back to the label Calculator. Clicking the link opens the WorkItemCalculations tab on the Layer destinations view for that calculator. The work-item row and card action menu entry formerly labelled Calculation assignments is now labelled Calculations and opens the WorkItemCalculations tab on its default Measurements view. Selecting a provenance link opens the tab on Layer destinations by passing focusCalculatorId. CalculatorProvenanceLink renders the calculator’s name as a visible link. The name is read live from the server — calculatorName can be null while calculatorId is not (a renamed calculator retains its id). A missing name falls back to the label Calculator. Clicking the link opens the WorkItemCalculations tab with focusCalculatorId set, which starts on the Layer destinations view highlighting that calculator (D68). WorkItemCalculatorProvenance shows one link per distinct calculator feeding a work item’s build-up. Distinct by id, not by name, because D58 makes names free text and not unique. activityComponents are not walked — the server does not enrich them with calculatorId. The server also returns calculatorReusesExcavatedMaterial: true on rows whose source layer has engine kind backfill (that is, the layer is configured with isBackfill: true). This flag is part of the provenance payload and lets the frontend distinguish reuse rows from bought-in fill without inspecting the calculator configuration. An unreadable legacy configuration does not grant the flag — the provenance function catches the parse error and leaves the field false. After a save, the refetch walks the union of where the calculation fed before and after. A layer moved off an item leaves that item’s rows changed too; both the old and new destination items are refetched. The Estimate API (stralos-api-estimate) runs a live calculator engine (@stralos-co-nz/calculators ≥ 0.3.0). Calculator create, copy, update, and link mutations are functional server-side. When a project-level calculator has a defaultWorkItemId and a layer has no existing link and has not been marked as decided, seedLinksForDefault seeds a link for it using the first outputKey the layer exposes. Measurement rows (layerKind === "measurement") are always skipped. A measurement is a geometry read-out — total length, saw cut length, footprint perimeter, excavation volume, pipe sticks, and each layer’s own area, length, and volume — that the estimator may choose to link manually. Auto-seeding them would materialise zero-rate component rows on every existing calculator’s next save. The predicate is isMeasurementRow from the engine package, not the m: id prefix. The activities API (stralos-api-activities) takes the identical skip. The seed order differs by layer kind: count is last in both orders and is selected only when the layer states no earlier output. A non-bulk reinstatement layer with a non-geometric unit currently states count alone, so it cannot displace a geometric output on another layer. A bought fill layer seeds looseM3 first because the estimator purchases loose cubic metres delivered to site, not the compacted void the material fills. Seeding inSituM3 understated the order quantity by the full compaction factor until manually corrected. A non-bulk reinstatement layer emits exactly one output — the one determined by its rate item’s unit (see NonBulkMaterialSnapshot). The seeder takes that output directly; no seed-order walk is needed. Excavation strata keep inSituM3 first. Excavation is paid for by the hole, not by the spoil heap, so in-situ volume is the correct transacted quantity. A global looseM3-first order would silently re-denominate every excavation link. A backfill layer is unaffected: fillLayerOutputs withholds looseM3 and tonnes from a backfill layer entirely, so the seed falls through to inSituM3 regardless of seed order. The estimator can change any seeded link on the work item detail page.

Calculator rename

When a calculator has its own name — that is, it is a project-level calculator opened from the Calculators page or minted from a work item’s row menu — the workbench header shows a pencil next to the title. Selecting the pencil opens an inline input. Saving sends a name-only PUT to the existing update endpoint ({ name } only, with no config payload). On success the header updates to the server’s returned name and the grid row is re-read. On refusal the server’s sentence is shown alongside the input and the workbench stays open. A project-level calculator opened from the Calculators page shows the pencil. A calculator whose config is stored as a legacy jsonb column (read-only; predates project-level calculators) does not show the pencil and cannot be renamed — its title is the host item’s own name. CalculatorsPanel renders as a DataTable (grid layout, not cards). Each row exposes four columns: Name (with an inline pencil rename control), Type (a KindChip), Summary, and Destinations (destination count). Row actions are Copy and Delete. Delete opens CalculatorDeleteDialog, which names every work item and activity the calculator feeds before the estimator picks Delete the rows too or Delete, keep the rows. The workbench’s Remove calculator opens the same dialog for a stored calculator and closes the workbench after the delete. The Summary cell comes from describeCalculator in calculatorRegistry.tsx: Trench: … for a trench, Pit: L × W × D · N structures for a pit, and Volume: L × W × D · N layers (or area × depth in areaDepth mode) for a volume. A pit or volume missing a dimension reads dimensions not set in place of the plan. A calculator with no saved geometry shows No dimensions entered yet.

Calculator rename (D80)

Estimators can rename a calculator without opening it. The Name (optional) cell renders a plain-text button that opens the calculator and a pencil icon beside it. Clicking the pencil swaps the name for an inline input; saving sends { name } only through PUT /calculators/:id. The workbench header title is also an inline editor: the same name-only PUT runs from inside the open workbench so a rename there does not save the geometry draft. A blank name is refused locally with the same toast that blocks blank work-item names (BLANK_NAME_REFUSAL). A server refusal is shown verbatim.

CalculatorModalHost

CalculatorModalHost in src/calculatorRegistry.tsx mounts the calculator for whichever calculator arm the CalculatorEditorSeed discriminates. It accepts a page prop: when page is "workbench" it renders the workbench frame (CalculatorWorkbenchPage); when absent or "modal" it renders the legacy modal for embedded editors. It delegates substance loading and fill-material loading to per-arm wrappers (TrenchCalculatorHost, PitCalculatorHost, VolumeCalculatorHost) that each call useDisposalSubstances and useFillMaterials once per open editor.
Dirty-close guard (hostDirty)
For the legacy embedded editors (modal frame), the modal’s own dirty-close guard compares JSON.stringify(draft) against a pristine snapshot of the geometry it owns. Two categories of unsaved work live in the host rather than in the modal, and are therefore invisible to that comparison:
  • Held name — an unsaved rename is staged in heldName (host state in CalculatorsPanel / WorkItemsPanel). The name-only PUT does not run until Save; closing before saving discards the rename silently under the modal-only guard.
  • Pending “Used by” assignments — every work-item choice made in the Used by picker is held by useLayerAssignments as a PendingAssignment and applied only on Save. hasPending (a boolean on the useLayerAssignments return value) is true whenever at least one such choice is waiting.
CalculatorModalHost accepts a hostDirty boolean and passes it explicitly to each of the three arms — never through a JSX spread, because a JSX spread of a variable is exempt from TypeScript’s excess-property check and a misspelled prop would drop the guard silently. The modal ORs hostDirty into its own dirty state, so closing the modal after renaming or making a pending Used by assignment prompts Discard changes? even when no geometry has changed. For the workbench frame, useCalculatorLeaveGuard owns dirty state directly and reads it via a ref at navigation time. See Calculator workbench route (D1). The host computes hostDirty as heldName !== null || editorAssignments.hasPending and passes it down. Nothing outside useLayerAssignments may act on which choices are held — that is flushPendingAssignments’s job — so hasPending is a boolean rather than the pending-assignment list. useDisposalSubstances issues two concurrent requests: listAllDisposalSubstances (paging GET /api/global/disposal-substances?status=all) and listSubstanceUsage (GET /calculators/substance-usage). Usage failures are silently discarded — the picker falls back to alphabetical order. List failures set { status: "error" } and do not retry. useFillMaterials pages every Bulk Materials rate item from GET /api/rates/rate-summaries?rateGroup=quarry_materials to exhaustion (200 per page). A failed load leaves the calculator open with the saved fill-material snapshot authoritative; estimators cannot pick a new material until the read succeeds. The quarry_materials token is a rate-group constant, not a category name — no organisation names a category “Bulk materials”. The three pricing props that pre-dated the 0.24.0 ui pin (rateSummaries, currency, loadRateOptions) are no longer passed to any calculator editor. Rate items, locations, and unit costs are now chosen on the work-item line through the ordinary pricing engine. CalculatorsPanel makes no rate-catalogue request. useDisposalSubstances passes each DisposalSubstance row from the Global API through to calculator-ui untouched, including the optional canUseForReinstatement flag. calculator-ui derives the flag when it is absent (for api-global deployments that predate the field). The host never derives or invents this flag.

Calculator config save error rewriting (describeConfigSaveError)

describeConfigSaveError in src/calculatorsApi.ts rewrites one specific 400 refusal before the calculator editor displays it. When the server returns 400 calculator_config_backfill_from_ineligible — a reinstatement layer references an excavated substance marked canUseForReinstatement: false — describeConfigSaveError replaces the server’s message (which begins with a raw config path) with the constant BACKFILL_FROM_INELIGIBLE_MESSAGE:
A reinstatement layer is filled from excavated material that can no longer be used for reinstatement — choose another material for it.
All other 400 refusals and 409 conflict prompts pass through unchanged. The rewriting lives in src/api.client.ts via rewordApiError, which preserves the error’s HTTP status, body, code, URL, and diagnostic while replacing only the user-visible message.

Activity-insert copy failure (activityCalculatorCopy.ts)

activityCalculatorCopyFailure (src/activityCalculatorCopy.ts) produces the message the insert review shows when copying an attached activity’s calculation fails. Two paths diverge on the error code:
  • calculator_config_backfill_from_ineligible — the copy is a POST /calculators with no stored config to exempt, so the same substance will refuse the copy on every retry. The message says the activity was added but the calculation could not be copied, followed by the reinstatement refusal sentence. No retry button is offered because retrying cannot succeed.
  • Any other error — the message says the activity was added but the calculation could not be copied, and offers Retry to finish without adding the activity again.
copyAttachedActivityCalculators tracks which copies have already succeeded in a Set<string> so a retry skips calculators that went through on the first attempt and only retries the one that failed.

Non-bulk materials (src/nonBulkMaterials.ts)

useNonBulkMaterials (in calculatorRegistry.tsx) loads every rate item from GET /api/rates/rate-summaries in full and maps them through nonBulkMaterialsFrom to the calculator’s NonBulkMaterialsState. The host passes this state as nonBulkMaterials to TrenchCalculatorModal only; pit and volume wrappers omit the prop deliberately (R8: neither arm has a length axis, and the pit’s backfill layer has no area). nonBulkMaterialsFrom excludes items whose rateGroup is quarry_materials or disposal — both already have dedicated controls on the calculator row. Every other item, including unclassified items (rateGroup: null), is offered. Inactive items are kept rather than dropped: the ui’s nonBulkMaterialChoices filter (status === "active" || option.id === layer.nonBulk.id) allows a retired item to remain visible for a layer that already names it. Each option carries id, name, unit (modeUnit ?? null), and status. The unit is modeUnit exclusively — not the legacy item.unit column (dropped in api-rates migration 0036) and not costRate.unit (the price range’s axis, which can name a unit for an item with no modeUnit). The rate item picker filters the item list by name as you type. A failed load leaves the modal open with the “Non Bulk Material” row present but with an error state — the estimator can still save a previously chosen item because the snapshot is already on the layer.

Multi-estimate module (src/multiEstimate/)

PRD 210 introduces multiple tender estimates per project. Since 2026-10-04 this is a project setting, not a PostHog flag: src/multiEstimate/flag.ts (useMultiEstimatesEnabled) reads multipleEstimatesEnabled from api-estimate GET /projects/:projectId/estimate-settings once per project per page load, and re-reads it when Project settings saves it (ESTIMATE_SETTINGS_CHANGED_EVENT, stralos:estimate-settings-changed). The setting is off unless set; projects that already held more than one live tender were backfilled on. Loading or a failed read answers “off”. Comments in the source still call it “the multi-estimates flag”. When the setting is off, the estimate page is the single-estimate page: the simple Mark won confirm, no Duplicate as new estimate on a version, and bare /estimate paths render directly rather than redirecting to the primary’s canonical address. Turning it off never deletes an estimate.

Estimate kinds and identity

Each estimate now carries a kind field: "tender" (all current estimates), "delivery" (the post-award Delivery copy), or "saved_version" (a historical snapshot). EstimateKind and EstimateListItem in src/multiEstimate/api.multiEstimate.ts are the canonical types; toEstimateListItem normalises server shapes so older API deployments degrade gracefully. isHiddenOrVersion — not-awarded (hidden) tenders and saved versions are read-only regardless of their status field. Only the server checks this definitively on write; the client checks it to gate editing actions up front.

Switcher and estimate sheets (workspace remote)

The estimate switcher, the Estimates sheet (create, rename, duplicate, make primary, delete) and their name helpers live in the workspace remote (RailEstimateSwitcher, ManageEstimatesSheet), not here; see Workspace frontend. The estimate remote’s EstimatesCard and estimateSheets.tsx have been removed. The estimate remote only opens the sheet and refreshes when it reports a change (see Manage Estimates sheet and in-place switching below).

Mark Won picker (MarkWonSheet)

src/multiEstimate/MarkWonSheet.tsx implements the Mark won action when the project’s Multiple estimates setting is on. It replaces the single-button confirm with a side sheet that lists the project’s submitted tender estimates, preselecting the open one, so an org admin or a lead of the project can name the one that was awarded. Mark won is only offered on an estimate that is itself Submitted. markWonCandidates(items) filters to live (not hidden) tender estimates in "submitted" status. markWonConsequence builds the confirmation: the chosen estimate is marked won, the project moves to the awarded stage, the chosen estimate becomes primary, and the contract baseline is created from its tender revision; the other estimates are hidden as not awarded, kept read-only, and can be shown from the estimate list.

Restore version dialog (RestoreVersionDialog)

src/multiEstimate/RestoreVersionDialog.tsx implements the PRD 210 R18 restore prompt. A graph-format version row in the versions sheet offers Restore… (and Duplicate as new estimate when the Multiple estimates setting is on); the Restore version N dialog appears before anything is written and offers two choices: The dialog fetches getRestorePreview(estimateId, versionId) automatically when it opens. restorePreviewSentence(preview, currency) words the result under the reprice choice: how many lines would change price and whether the total goes up or down, plus a day-rate line saying how many day-rate lines would move to the project’s current day-rate revision. What a version row offers depends on its storage (VersionActions in src/EstimateVersionsModal.tsx): a graph version gets Restore…; an old JSON version gets View only (R22); a server that predates PRD 210 (storage === "legacy") keeps the old Create from this version flow.

Primary estimate redirect (primaryEstimateRedirect.tsx)

src/multiEstimate/primaryEstimateRedirect.tsx provides PrimaryEstimateRedirect, which reads the project’s estimate list and redirects bare /estimate URLs to the primary estimate’s canonical address (/projects/:projectId/estimates/:estimateId/…). Only when the Multiple estimates setting is on; when it is off the bare paths render in place.

Manage Estimates sheet and in-place switching (PRD 210 §8.1)

src/multiEstimate/manageEstimates.ts owns the cross-remote event contract for managing a project’s estimates. The Estimates sheet lives in the workspace remote (on every project page), not in the estimate remote. The estimate remote asks the workspace to open it via a custom event and refreshes its own page when estimates change. Events: Overview button: the Manage estimates button in EstimatePage’s overview tab calls openManageEstimates(projectId), which dispatches OPEN_MANAGE_ESTIMATES_EVENT. The legacy ?manage=estimates query parameter is still honoured: on load, EstimatePage detects the param, strips it from the URL, and dispatches the same event. Change refresh: stralos:estimates-changed triggers refreshLifecycleState with a background flag. The page re-reads its lifecycle state without remounting. A change from another project’s estimate is ignored. In-place estimate switching: A switch between two estimates of the same project is not a reload. EstimatePage is keyed on the project, not the estimate, so React does not unmount the component. When the URL estimate segment changes to a different estimate of the same project:
  1. switchPending and estimateSwitching become true.
  2. The page content is wrapped with inert and aria-busy — all interactions are disabled and the screen dims while the new estimate loads.
  3. The URL updates immediately to name the new estimate.
  4. The new estimate’s lists load in the background. Once ready, the data swaps under the same panels.
  5. If the new estimate list does not arrive within 15 seconds, the inert guard times out and releases the page.
A project change, a direct reload, an unreadable estimate, or the bare project path still trigger a full load. Work item carry-across switch (src/WorkItemsPricingPanel.tsx): When a work item is open and an estimate switch happens, carryAcrossSwitchRef records the outgoing item’s estimateId, workItemId, itemNumber, and name. Once the new estimate’s list arrives, the panel:
  • Looks up the item by itemNumber (preferred) or by exact name when there is exactly one name match.
  • If found, opens the matched item and rewrites ?wi= to the new item’s id.
  • If not found, returns to the list view and shows a toast: "Item X.X is not in this estimate" (or "<name> is not in this estimate" for unnumbered items).
While a switch is pending the panel holds the outgoing estimate’s id so its autosaving pricing never pairs the open item with the incoming estimate.

Delivery (src/delivery/)

The Delivery module is controlled server-side by the ESTIMATE_DELIVERY_ENABLED flag. When the flag is off, the server returns 404 delivery_disabled on all Delivery routes and the client renders DeliveryUnavailable. Client-side there is no additional flag; a deep link in test or prod lands on the “not switched on” state rather than a broken page.

Delivery pages

useProjectDelivery(projectId) fetches the project’s Delivery estimate list item once and exposes it with a reload callback. deliveryShared.tsx owns the empty states, page frame, signed-money display (SignedMoney), and the DeliveryStatusBadge.

Delivery sign-off state machine

availableDeliveryActions(status, isLead) computes which transitions the page offers. The lead check is advisory; the server re-checks the role at decision time.

Variation rows on the estimate schedule

src/variationRows.ts reads the variation and tracking-sell wire fields that an approved variation appends to the estimate schedule. These fields are read defensively — an older server that does not send them produces “no variation rows” rather than a type error. variationWireFieldsOf(row) copies only the keys that are present so a row from an older server adds nothing to the mapped item. variationFieldsOf(item) reads these fields for display logic. The estimate schedule groups variation rows into sections headed by the variation reference (e.g. “VO-3 · Pipe upgrade”) and shows the tracking sell read-only on a Delivery estimate with no contingency.

Quarry supplier route resolution (src/quarrySupplierRoutes.ts)

Distance is measured per supplier location: each quarry site is measured on its own, never per company. useQuarrySupplierAnalysis in src/quarrySupplierAnalysis.ts collects every site a quarry price names and calls measureProjectLocationRoutes (src/api.global.ts). A supplier with an all-locations price is sent as a company id, and api-global adds every active site that company owns.

Partial-results request

measureProjectLocationRoutes sends partial: true to POST /api/global/projects/:projectId/location-routes/resolve in batches of 200 (api-global’s MAX_LOCATIONS). The server measures and caches missing or stale legs, returns the measured sites in routes, and names every site it could not measure in unroutable with a reason. A site the answer does not mention is recorded as not_measured. If a measuring call fails, the client falls back to the cached GET /api/global/projects/:projectId/location-routes read for the same batch. A routing outage then costs only the sites not yet measured, not the whole read. A failed cached read still throws. The company-level POST /api/global/projects/:projectId/supplier-routes/resolve and the client’s bisection fallback are gone; api-global no longer serves that route.

Failure reasons

QuarryRouteFailureReason is a site’s reason from UnmeasuredLocation:

Failure notices (describeQuarryRouteFailures)

describeQuarryRouteFailures in quarrySupplierRoutes.ts groups failures by remedy and produces one QuarryRouteFailureNotice per remedy present, in this order: no address (actionable), not found (a data problem), no route, then not measured. Each notice carries a kind, a heading (title), the affected sites as “Supplier — Site” (up to six names, then “and N more”), and a detail sentence. Rates for these sites are still priced; only the distance is missing.

routeFailures state field

QuarrySupplierAnalysisState carries a routeFailures field alongside error. These are distinct: error is set only when the whole read fails and the comparison panel cannot be shown. routeFailures is populated when some supplier sites could not be measured but the rest of the data is valid — a partial read, not a failed one. The panel shows route-failure notices below the comparison table while error stays null. The field is optional on the type so pre-existing fixtures that do not set it read as “no failures”, which is both the correct interpretation and the state before this field existed.

Stored monetary figures

Stored figures are the single source of truth for money on the estimate screens. api-estimate (migration 0106) stores every work item’s sell rate to the cent (unitRate) with totalPrice = round2(quantity × unitRate), each section’s subtotalPrice, and the estimate’s itemsTotal, contingencyTotal, tenderTotal, and unsectionedTotal. The estimate totals ride on the GET /estimates/:id/sections envelope.

Item sell rate

workItemUnitRate(item, "price") in src/workItemMoneyDisplay.ts returns item.unitRate whenever it is non-null. Only a null unitRate (a provisional sum, a quantity of zero, or a frozen row no cent rate reproduces) falls back to dividing totalPrice by quantity. There is no stored cost rate, so the cost rate is always derived. WorkItemsPanel reads the rate through this helper. TenderPricedItemsPanel uses item.unitRate directly as a 2 dp reconciling rate and calls reconcilingRate only for a row with none.

Section subtotals

sectionSubtotalPrice(subtotalPrice, sumRows) in src/workItemOutline.ts returns the stored subtotalPrice and calls sumRows() only when it is null. The Unsectioned group has no section row, so callers pass storedTotals.unsectionedTotal. WorkItemsPanel, TenderPricedItemsPanel, and WorkItemNavigation use it for section headings and rail subtotals.

Estimate totals

StoredEstimateTotals (src/api.types.ts) is { itemsTotal, contingencyTotal, tenderTotal, unsectionedTotal }, all nullable. The page keeps it in storedTotals state and passes it to SummaryPanel, WorkItemsPanel, TenderPricedItemsPanel, and WorkItemsPricingPanel. estimateCommercialBuild (src/commercialBuild.ts) takes stored as its last parameter. When storedTenderTotals(stored) returns a value (both itemsTotal and tenderTotal non-null), the subtotal, contingency, and tender price are those figures and estimateTenderTotals is skipped. The cost total is always summed from the rows. A null stored contingency is derived as tenderTotal − itemsTotal.

Tests

src/storedItemFigures.test.tsx builds every fixture so the stored figure and the re-derived one differ (quantity 0.3 × unitRate 33.35 = 10.005 → totalPrice 10.01, but 10.01 ÷ 0.3 = 33.37), so a surface that still derives fails. Add a case there when touching any of the surfaces above.

Estimate rounding

The flag and whole-stock helpers live in src/estimateRounding/. Both rounding controls sit behind the estimate-rounding PostHog flag, read with useEstimateRoundingEnabled() (fallback: false). The flag is frontend only: api-estimate always honours a stored rounding, so a link or estimate rounded while the flag was on still prices rounded with it off.
  • QuantityLinkSheet shows Round up to only where Adjust is shown and the host passes roundingOffered (false for a whole-stock build-up line, see isWholeStockLine in src/estimateRounding/wholeStock.ts, which mirrors api-estimate’s check). A new link follows suggestQuantityRounding until the estimator picks; an existing link, or any link with the flag off, saves its stored rounding verbatim.
  • TenderPricedItemsPanel shows the estimate’s Round sell rates up to (saved by updateEstimateSellRateRounding, a PUT /estimates/:id with sellRateRounding) and a per-item Round sell rate up to column (updateWorkItemSellRateRounding, a PUT /work-items/:id; null inherits the estimate). Items in a Shared Costs section, provisional sums, and zero-quantity rows get no control. unitRateUnrounded is the rate tooltip, and workItemRateMarginsWithRounding counts the uplift as margin in the percentages.
See Estimate rounding for the option lists and the rounding contract.

Tender download (src/TenderDownloadPanel.tsx)

The tender-download Tender Submission section (after tender-check in tenderSectionNavigation) owns the PDF, Excel, and original-workbook downloads; TenderPricedItemsPanel keeps only a pointer link. The panel reads the project scope fresh (getProjectScope), the company terms (getOrganisationTerms), and the project documents (listAllDocumentsByProject, filtered by receivableDocuments).
  • A section that is known to be empty (no scope, no terms, no documents) has its tick disabled and is sent as false. A read that is still loading or failed is not known empty: holdReason holds the download instead, matching api-estimate, which never drops a ticked section silently.
  • sections and documentIds are sent with downloadSubmissionPdf and downloadScheduleSubmissionXlsx (layout submission). The original-workbook export sends neither.
  • downloadErrorMessage shows api-estimate’s own message for the tender_terms_unavailable, tender_documents_unavailable, and tender_project_scope_unavailable 502s.
  • The panel shows the saved project scope read-only, with Edit in project settings to change it. Scope editing and Create scope with AI live in ProjectScopeCard in stralos-feature-project-settings (src/projectScopeAi.ts calls api-documents POST /projects/:projectId/tender-scope-draft).

Formatting and review safeguards

Percent format (src/percentDisplay.ts)

All percentage values rendered by this remote now go through displayPercent or displayRatioPercent from src/percentDisplay.ts. The canonical format is number, space, percent sign — "15 %". check:display-format (scripts/check-percent-format.mjs) fails any hand-rolled ${x}% template in production source. Precision is the caller’s statement: use { fixed: N } where column alignment requires fixed places, and omit it where the value’s own precision is enough.

Rate exception same-unit sentence (src/RateExceptionsPanel.tsx)

rateExceptionSentence now detects when both sides of a measured in X, priced in Y clause format to the same display unit (for example m2 and square_metre both rendering as m²). In that case the formatted sentence would contradict itself. Instead it replaces the clause with the line and the rate both use m², but under different unit records and the resolution with re-pick the unit on the line.

Takeoff review: only the open decision is offered (src/takeoffSubComponents.tsx)

TakeoffReviewPanel now shows only the action that can still be taken. When decision === "approved", only Remove from takeoff (which calls onDecide("rejected")) is rendered. When decision === "rejected", only Approve for takeoff is rendered. When the decision is null (pending), both are shown. The current state is always shown as a chip, never as a primary button.

Add contractor as a sheet step (src/SubcontractsPanel.tsx)

The Add contractor create is a step inside the package SideSheet. Clicking Add contractor retitles the open sheet and swaps its body for the contractor form. Back returns to the package form without saving. The flow uses one sheet rather than stacking a centred dialog above it.

Virtual scrolling (VirtualTableBody and InfiniteList)

Several large tables and card lists in the estimate remote render only the rows in the viewport. This keeps frame times low on packages or rate lists with hundreds of rows. VirtualTableBody (from @stralos-co-nz/ui 0.65.0) wraps the <tbody> of a Chakra Table.Root. It is a drop-in replacement for a plain <tbody> and owns all row measurement and recycling internally. Two surfaces use it: InfiniteList (also from @stralos-co-nz/ui) renders a windowed card or row list. These surfaces pass items and renderItem, with totalCount and itemKey where needed; both of the latter props are optional. It is used for mobile card views and supplier grid bands: PROJECT_RATES_PAGE_SIZE (exported from ProjectRatesPanel) is a legacy constant retained only so that callers that previously drove a manual page loop do not need to change. Rendering is now handled by InfiniteList’s own virtual window; the constant does not control how many rows are mounted. Opt-out: Resource summary table. The Resource summary table in SummaryPanel (src/SummaryPanel.tsx) passes virtualized={{ enabled: false }} and layout="fixed" to DataTable. Under windowing, a change in mounted rows re-wrapped long resource names, which changed measured row heights and moved the window again, until React hit its maximum update depth and the Overview fell into its error boundary. Fixed layout takes its widths from SUMMARY_COLUMN_WIDTHS, so names cut off instead of re-wrapping. The table holds one row per distinct resource, a bounded list, so it renders whole. src/summaryResourceTableUpdateDepth.test.tsx covers the regression.

Resource summary grouping

groupSummaryResources (src/summaryPricing.ts) groups the summary’s resource rows into SummaryResourceGroup bands in RESOURCE_GROUP_ORDER (Labour, Plant & Vehicles, Materials, Bulk Materials, Disposal, Subcontractors, Day rates, Other), skipping empty groups. Each band carries its rows, totalCost, totalPrice, and choiceNeededOnly. filterSummaryResources applies the SummaryResourceFilter (search, groups, categories) first; summaryResourceFilterChoices (in SummaryPanel.tsx) builds the group chips and category choices from the rows so every choice finds something. SummaryPanel passes the bands to DataTable as sections with sectionCollapse (bands start open). The filters sit in the panel’s filter slot with a live Showing X of Y resources count. While a filter is active the adjustment rows are left out, and the Estimate total (all resources) row is always built from the commercial totals, so it covers the whole estimate. Optional columns drop out as the card narrows in OPTIONAL_SUMMARY_COLUMNS order: margin, average unit cost, category, items.

Find a rate sheet (src/findRate/)

FindRateSheet (src/findRate/FindRateSheet.tsx) opens from the Search rates icon (FIND_RATE_BUTTON_LABEL) on a build-up add row that takes items, bound to that row’s target (the work item or an activity). It filters the rate list the build-up already holds (Rates Library rates only; project rates keep their own sheet) in the browser with filterFindRates (src/findRate/findRateModel.ts); no new request is made. The filters copy the Rates Library’s: search over name, code, and supplier names, then category (grouped by rate group), company (your company first, then suppliers), and unit, with Clear filters and a live count line. At most 200 matches are listed. Add calls the row’s own add function, so routing and charge basis match typing the name: a rate from the work item’s own row goes into a new activity. foundRateQuickAddInput prefills an hourly rate’s quantity with the estimate’s hours per day and every other rate with 1. An added rate is marked Added and the sheet stays open; all Add buttons are disabled while a write is in flight. The footer’s Make a new rate (when the host passes onMakeNewRate) opens the rate editor prefilled with the search text; the host adds the saved rate through rateSummaryFromNewRate and announces it with addedNotice. The filter menus portal inside the sheet so they open above it.

Count lines

Estimate lists use the @stralos-co-nz/ui count line (ListCount, see Design library): Filtered to N … while a toolbar filter narrows the list, Showing all N … otherwise.

Soft-delete confirm copy (src/estimateShared.tsx — P2-6)

Every red (destructive) confirmation dialog in this remote is a delete that the server keeps (deleted_at). The default reversibility note shown under the message is therefore “It’s kept for history.” rather than “This cannot be undone.” This default is applied by defaultReversibility in src/estimateShared.tsx, which supplies { reversible: false, note: KEPT_FOR_HISTORY } whenever confirmTone resolves to "destructive" and the caller does not pass reversibility explicitly. The constant KEPT_FOR_HISTORY is "It's kept for history.". Two surfaces opt out and keep the plain irreversible line by passing reversibility: { reversible: false } directly:
  • Discard measurement in progress (takeoff drawing) — the in-progress measurement is truly lost; the server never held it.
  • Unlock SOQ — irreversible in scope; not a delete.
Variation withdrawal also keeps the plain line. A withdrawal changes a variation’s status on the register rather than deleting a record. The confirm title follows the copy standard "Delete {thing}?" and the body names what the record is removed from, without restating “this cannot be undone”.

Lump-sum change no-change gate (src/VariationsPanel.tsx)

VariationLineEditorModal now blocks saving a lump-sum change whose varied amount equals the frozen agreed amount. The save button is aria-disabled and the editor shows Enter a different amount while the amounts match. The preview line value is hidden while the line would change nothing.

Unit catalogue (workItemUnits.ts)

The unit list is not owned by this remote. It lives in api-global’s unit_catalogue table (what the served catalogue API sends), @stralos-co-nz/api-common (backends), and @stralos-co-nz/ui (frontends). The picker offers exactly what the served catalogue sends, in the served order, with the served names and symbols. @stralos-co-nz/ui’s UNIT_CATALOGUE is the offline fallback used only while the unit API cannot be reached. useWorkItemUnits in src/workItemUnits.ts loads the catalogue via listUnitCatalogue (GET /api/global/unit-catalogue) and exposes it as a useSettingList-managed list. While the API is in flight, isFallback is true and the picker falls back to ui’s own copy. Once the served rows arrive, the picker uses them exclusively. resolveWorkItemUnitDisplay resolves a stored unit code to its display symbol. A stored value is not always a catalogue code (SOQ-imported rows carry the workbook’s spelling). Resolution order: the served catalogue by code, then the catalogue by canonicalised code (canonicaliseUnit), then formatUnit. This keeps the Unit column consistent even for rows imported before the catalogue was served. Units added to api-global’s catalogue — such as length (len), roll_length, roll_area, and sheet — become available in the picker automatically without any change to this remote.

Unit picker alias resolution (WorkItemUnitSelect — D3)

WorkItemUnitSelect (src/WorkItemUnitSelect.tsx) uses the shared canonicaliser (canonicalWorkItemUnitValue) in both its match predicate (unitMatches) and its commit path (unitNamedBy). This means an estimator can type a legacy or alias spelling (m3, cu m, t, tonne) and Stralos resolves it to the correct catalogue code on blur. The previous behaviour silently committed metres when a typed alias did not find an exact symbol or name match. Three exit states on blur:
  • Typed entry names exactly one unit (by alias, symbol, or name) → that unit is written, as if it had been picked from the list.
  • Typed entry matches no unit → field stays open with the text on show, sets customValidity to "No unit matches", and marks the field invalid. The form cannot be submitted while the field is invalid; the previous unit is not restored.
  • Typed entry is a partial search that still matches some rows → the field reverts to showing the committed unit.
unitMatches runs canonicalWorkItemUnitValue(needle) === option.code as a fourth check after label, value, and code comparisons, so the full alias table is searchable rather than only the catalogue symbols.

Negative markup / margin warning (NegativeMarkupWarning — V1)

NegativeMarkupWarning (src/NegativeMarkupWarning.tsx) renders an Alert.Root status="warning" whenever a percentage argument is a finite number less than zero. It is used in two places:
  • Estimate settings — beside the default markup or margin field when the saved value is negative.
  • Tender Submission → Margins — beside any rate-group or item-category rung whose effective percentage is negative. The warning names the rung (via negativeMarkupSentence’s optional subject parameter) so adjacent rungs are unambiguous.
The warning is informational only. No value is refused. It persists as long as the number remains negative. negativeMarkupSentence(percent, basis, subject?) produces the display text. It uses basisNoun(basis) to say “markup” or “margin” and displayPercent(percent) for the formatted value.

Margin rung provenance (marginRungReport.ts — F2)

marginRungReport.ts provides marginRungLines and pricedByLabel, which power the Which markup/margin priced each line table in TenderMarginsPanel. pricedByLabel(source, component, ladder, namers) converts a PricedMarginRung enum value ("component", "category", "rate_group", "tender", "organisation") into a human-readable string. For "category" and "rate_group" rungs it appends " (this tender)" or " (organisation)" by looking up the rung in the ladder to establish who set it. marginRungLines reads marginSource and marginPct directly from the server’s component rows. This is deliberate: a client-side re-walk of the ladder would return the ladder’s current state, which can differ from what the server actually applied (for example, a category override that saved but matched no component because all components carried a null category). Reading the recorded rung makes such discrepancies visible rather than hiding them. Activity containers (isBuildUpContainer(component)) are excluded from the list. An activity container’s money is the sum of its children’s rows, each of which appears in the table on its own row. The server records a rung on the container too, so without this exclusion an activity’s lines were listed twice — once under the container and once under each child line. The table is only rendered when pricedLines.length > 0. Lines with no recorded marginSource (for example, lump-sum or provisional-sum items that derive their price from an allowance rather than the margin ladder) are omitted from the list.

Day-rate line display helpers (dayRateLines.ts and DayRateLineNotes.tsx)

src/dayRateLines.ts is the single source of truth for how a contractual day-rate build-up line is described and priced on the client. It is pure — no React, no requests — so the build-up pane, the picker, and the tender review margin table all word the same line the same way. dayRateChargedRate(component) — the rate a day-rate line is charged at. This is the value the Rate cell displays and what WorkItemRateMarginsModal shows in the tender review.
⚠ This is not unitCost alone. api-estimate keeps a day-rate line’s unit_cost column at the schedule’s published rate and records the hand-edited rate only in the unit_cost value override. A rate cell that reads unitCost directly shows the schedule rate after a reload and does not respond to Revert. Use dayRateChargedRate for every surface that shows what the line is charged at. WorkItemDetailPane and WorkItemRateMarginsModal both call it for their Rate columns. dayRateRateOverride(valueOverrides) — returns the unit_cost value override when one exists, or null. A non-null result means the estimator has hand-edited the rate. dayRateEditedNote(dayRate, override, currency): produces the Edited (schedule $X) badge text. When a schedule update moved the line to a newer revision after the edit, returns Edited (schedule now $Y) instead (pickedAt predates overriddenAt). dayRateOutOfStepNote(dayRate) — produces the out-of-step badge text when the line’s revision is no longer current on the project. Returns null while the line is in step. noLongerInSchedule wins over a revision mismatch; an unreachable rates service returns null rather than a false alarm. DayRateLineNotes and DayRateSourceSlot (src/DayRateLineNotes.tsx) render the two slots beside a day-rate line’s description in the build-up. DayRateSourceSlot renders the 168 px source column (the slot a rate selector or “Manual rate” takes on non-day-rate lines). DayRateLineNotes renders the Edited badge and Revert button when a rate override is present, and the out-of-step badge when the revision has moved on. Both use the pure helpers from dayRateLines.ts.

Overlay and side-sheet behaviour

The estimate remote uses SideSheet for forms that benefit from keeping the underlying estimate visible and for flows that need more horizontal space. Work-item forms (src/workItemSheetConversions.test.tsx) — six create/edit screens use SideSheet: SectionEditorModal, EstimateSettingsModal, PricingComponentEditorModal, WorkItemTagsModal, ActivityInstanceEditorModal, and WorkItemEditorModal. SimilarWorkItemsModal stays on a centred 960 px modal. Cross-workspace forms — four overlays across Documents, Overview, Takeoff, and Tender use SideSheet:

Tender clause included/excluded toggle (TenderItemsPanel)

The included/excluded checkbox on each tender clause is optimistic. toggleIncluded in src/TenderItemsPanel.tsx works as follows:
  1. A unique Symbol is created for the request and stored in latestToggleByClause (a useRef<Map<string, symbol>>).
  2. The clause list is updated immediately with an inverted included value (optimisticClause).
  3. upsertProjectTenderClause is called. When it resolves, the server’s returned clause replaces the optimistic value — but only when latestToggleByClause still records this request’s symbol. A stale response from an earlier toggle is silently discarded.
  4. On failure, the same staleness check applies: if this is still the latest request, the optimistic update is rolled back to the original clause value and an error toast is shown.
This means rapid successive toggles on the same row never produce a race where an older, slower response overwrites the newer one. The pattern is modelled after the build-up component removal undo in useRemoveComponentUndo; keep both in sync when changing the error-toast vocabulary. Two related flows use a page or popover instead of a sheet:
  • Document suggestion review — a page rendered at /documents/review[/:documentId]. The document list is replaced by the review page while reviewing is active. There is no close button on the header — the exit is the Close button rendered by SuggestionsReview. Browser back leaves without passing the dirty-close guard; a useBlocker guard remains a follow-up.
  • Document folder editor — a PopoverRoot inline on the document card (RelatedFolderPopover / RelatedFolderForm in src/ProjectDocumentsPage.tsx). It mounts only while the popover is open so the input resets on reopening.
The SideSheet is built on the same Chakra dialog anatomy as Dialog, so getByRole("dialog") matches both. The discriminator in tests is data-placement, which only SideSheet emits. src/workItemSheetConversions.test.tsx pins this for Slice A screens; Slice C screens rely on their existing form-level suites plus the dirty-close guard.

D&C schedule filter (dcScheduleFilter.tsx)

src/dcScheduleFilter.tsx provides the URL-based filter that narrows the Work Items schedule to the estimate items linked to a specific Departure & Clarification clause.

URL parameter

The filter uses the query parameter ?dc=<clauseId>. The D&C panel’s linked-item count button navigates to the Work Items tab with this parameter set (a push, so Back returns to the D&C list). The parameter is resolved by the Work Items panel itself rather than the navigation action, so a copied URL, a page reload, and browser back all preserve or drop it correctly. dcFilterIdFromSearch(search) reads the param from a location.search string and returns the clause ID, or null when the param is absent or blank. withDcFilter(href, clauseId) rewrites an absolute URL (the current address bar value) with the param set or removed, returning a path+query+hash string suitable for a router navigation. It edits the address bar directly rather than the router’s stale location, so a simultaneously open detail pane (?wi=) is preserved. Clear filter calls this with null and replaces the history entry (not a push) so Back goes to the page before the filter, not into it.

Filter resolution (useDcScheduleFilter)

useDcScheduleFilter resolves a clauseId into a WorkItemsFilter shape:
  • Loading — until the tender clause list and per-item tag reads have settled, ids is empty so the full schedule never flashes before narrowing.
  • Ready — ids is the set of work-item IDs linked to the clause. groupLabel names the clause’s group. incomplete is true when one or more per-item tag reads failed, surfaced as a note in the banner.
  • Missing — the clause ID was not found in the estimate (deleted clause or link from a different estimate). The schedule falls back to showing all rows; the banner explains why.
  • Failed — the clause list could not be read at all. Same fallback as missing.
Per-item tag reads (useLinkedWorkItemsByClause) are keyed on each item’s tagCount and updatedAt, so the map refreshes when a link is added or removed but stays stable across unrelated item edits.

Row selection

idFilterRowIds(filter) translates a WorkItemsFilter to the Set<string> of IDs the schedule should keep, or null (show all rows) when the filter is absent, missing, or failed. withAncestorWorkItems(matched, items) expands a matched set to include the parent work item of every matched sub-item, so a linked sub-item never appears without its parent row. ancestorSectionIds(rows, parentById) collects the ancestor section IDs for a set of rows so that nested matches keep their section headings visible. Renders above the work items list when a WorkItemsFilter is active. States:
  • Loading — “Finding the estimate items linked to this D&C…”
  • Missing — “That D&C couldn’t be found — it may have been deleted. Showing all N estimate items.”
  • Failed — “The D&C filter couldn’t be loaded. Showing all N estimate items.”
  • Ready — D&C filter badge, clause short name, group badge, “Showing M of N estimate items linked to this D&C. Totals are for the whole estimate.” An optional note appears when incomplete is true.
A Clear filter button appears in all states when onClear is provided. It calls withDcFilter on the current address bar URL with null and performs a replace navigation.

Accessibility

useFilterAnnouncement mounts a role="status" polite live region alongside the panel. It announces each settled filter state once. The region is empty on mount so it does not fire on initial render; the same sentence repeated twice in a row is still announced as a second event by clearing then setting the message with a 50 ms delay.

Sheets beside the open assistant (@stralos-co-nz/ui 0.73.1)

From @stralos-co-nz/ui 0.73.1, SideSheet opens non-modally when the assistant panel is already open. The estimator can interact with both simultaneously: the sheet anchors to the same edge it would normally use, the assistant panel stays visible, and neither overlay blocks the other. The sheet chooses modality when it opens: below md it opens full-screen and modal. Later resizing or assistant visibility changes do not alter that choice until the sheet is reopened.

Build-up component removal (useRemoveComponentUndo)

useRemoveComponentUndo (src/useRemoveComponentUndo.tsx) handles the Delete component action on a work item’s build-up. It uses useUndoAction (ui 0.60.0) instead of a confirm dialog: the row disappears immediately and a toast with an Undo button gives the estimator a short window to reverse it. The DELETE runs only after the window elapses or a second removal commits the first one early — never on Undo. No spurious PATCH before DELETE: deleting a build-up line no longer sends a PATCH …/pricing remove for the line’s hidden override entry before the component DELETE. The previous sequence caused a 422 on a frozen selection (the server refused to remove an override on a frozen row), which surfaced as a false “Couldn’t save pricing changes” error even though the component was about to be deleted. The component DELETE is the authority on override cleanup. Since PRD 211 F the pricing PATCH carries pricingSource only (see Work-item pricing PATCH), so a removal sends no pricing PATCH at all. The hook is lifted out of WorkItemsPricingPanel so its three promises can be held in tests without mounting the full pricing surface. WorkItemsPanel passes onRemoveComponentRequested down to the pricing pane, which calls request(component, commit) on the hook. Write safety guarantees:
  • Navigating away while a removal is pending (to another estimate section or project) unmounts the hook. useUndoAction commits in that same cleanup — immediately, not when a timer that outlived the screen fires.
  • Closing or reloading the tab: a beforeunload listener asks the browser to confirm. A pagehide listener commits what it can.
  • A server refusal un-hides the row and shows the error reason in a toast ("<component name> was not removed"). The row reappears the moment the commit starts, so a failure restores it with the reason rather than leaving it absent with no explanation.
Optimistic totals: while a removal is pending, the hook hides the row from displayItems and adjusts the host item’s totalCost and totalPrice proportionally. The build-up header and row totals stay consistent on screen. The server’s figures replace both after the DELETE and its following refetch.

API layer

Runtime URL resolution

The estimate remote resolves its API base URLs through this chain (highest to lowest), applied separately for each module (global, estimate, rates, activities):
  1. VITE_STRALOS_*_API_URL — Vite environment variable (e.g. VITE_STRALOS_ESTIMATE_API_URL).
  2. window.__STRALOS_API_URLS__.<module> — runtime seam the shell can inject.
  3. localStorage.getItem("stralos.<module>ApiUrl") — per-module localStorage override for local development.
  4. sameOriginFallback(<localhost default>) — in a production build returns the current window.location.origin; under Vite dev returns the localhost default.
Default localhost ports: Global 3300, Rates 3301, Activities 3302, Estimate 3303.

Authentication

authedFetch in src/api.client.ts handles all authenticated requests. On a 401, it attempts a single token refresh through window.__STRALOS_AUTH__.refreshAccessToken (the shell’s refresh bridge). If the refresh also returns 401, the session is unrecoverable. Before redirecting to /login?reason=expired&from=<encoded-path>, endDeadSession() clears the three session keys (stralos.auth.accessToken, stralos.auth.refreshToken, stralos.auth.user) from localStorage. It prefers the host bridge’s window.__STRALOS_AUTH__.clearSession() when available, and falls back to removing the keys directly. Clearing the session before the redirect is required. The shell’s /login route treats a session as live if any of those keys are present (isAuthenticated() checks accessToken !== null && user !== null), so a redirect that leaves the keys in place sends the user straight back to the page that failed — with the same dead token. The same pattern applies in src/documentsApi.ts for documents requests. The first-401 path (where the refresh itself fails) is left to the host’s performRefresh, which clears the session before resolving null. endDeadSession does not run on that path to avoid duplicating the responsibility. When the shell bridge is absent (standalone development without the shell), the client falls back to re-reading the token from localStorage.

Workspace notification after project requests (projectRequestEvents.ts)

authedFetch passes authenticated API responses to observeProjectRequest (src/projectRequestEvents.ts). The observer fires a stralos:project-request custom browser event for a successful GET or HEAD, a successful non-OPTIONS mutation, or a 403/404 response so the workspace shell can revalidate its own project data. Other outcomes do not emit an event. The event detail carries { version: 1, path, kind } where kind is one of: Successful reads notify the workspace because an RLS-filtered list can return 200 with no rows after a project is removed or access is revoked. The event carries no response payload, bearer token, or project data. The event is only dispatched when the browser pathname at the time the response arrives matches the pathname captured when the request was issued (path && window.location.pathname === path). A response that arrives after the user has navigated away does not trigger revalidation for the new page. Suppressed duplicate project read: the estimate remote previously issued its own project GET to check access whenever projectRequestEvents resolved. This was redundant — the workspace shell already owns the authoritative project read. The estimate’s projectRequestEvents module now mirrors the workspace copy: it no longer performs an additional project GET. A refused response (403/404) is still reported as kind: "refused" so the workspace can revalidate; only the successful read event that was being emitted for the estimate’s own check is suppressed.

Busy-read retry (busyRetry.ts)

fetchWithBusyRetry (src/busyRetry.ts) wraps the estimate remote’s fetch calls. When a GET or HEAD request returns HTTP 503, the module waits for the Retry-After header’s stated number of seconds (up to MAX_BUSY_RETRY_WAIT_MS = 10 s, defaulting to DEFAULT_BUSY_RETRY_WAIT_MS = 1 s when the header is absent), then issues one silent second attempt. The result of the second attempt is returned regardless of its status; no further retry is made. Safety invariants:
  • Reads only. A 503 does not confirm the request was not applied. POST, PUT, PATCH, and DELETE responses are returned immediately without retry.
  • Single retry. One quiet retry per request — never a loop.
  • Abort-aware. If the request’s AbortSignal fires before the wait or the second attempt, the first response is returned immediately.
Both API clients (api.client.ts and documentsApi.ts) wrap their fetch with fetchWithBusyRetry.

Pricing panel scroll behaviour (WorkItemsPricingPanel)

WorkItemsPricingPanel manages two view modes: list (the pricing grid) and split (the grid replaced by the detail pane). Scroll position is handled differently for each transition:
  • Opening the detail — when the estimator selects a work item from the list, listScrollRef captures the current list scroll position via readWorkItemListScroll(), scrollOpenedDetailToTopRef is set to true, and the view switches to split. A useLayoutEffect that watches viewState.mode fires on the next render, detects the flag, and calls window.scrollTo({ top: 0, behavior: "auto" }). This ensures the detail pane header is always visible at the top of the screen rather than inheriting the list row’s scroll position.
  • Returning to the list — the three paths that set viewState back to list (URL change, back navigation via popstate/history.pushState, and the close button) each call window.requestAnimationFrame(() => restoreWorkItemListScroll(listScrollRef.current)). This restores the row the estimator was looking at before opening the item.
The scrollOpenedDetailToTopRef flag is reset immediately after the scroll so a subsequent tab switch or re-render within split mode does not retrigger the scroll-to-top.

Project readiness (src/projectReadiness.ts)

Some pages in the estimate remote require the project’s estimate record to exist before they can load. A newly created project may not have that record yet — the provisioning service creates it asynchronously. When a page opens before provisioning is complete, the API answers GET /projects/:id/estimate with 409 project_not_provisioned (with a Retry-After: 5 header). src/projectReadiness.ts is the estimate remote’s half of the readiness system. useProjectReadiness and ProjectPreparing from @stralos-co-nz/ui 0.68.0 are the other half. The project error codes and classifyProjectReadinessError come from @stralos-co-nz/domain (3.1.0 or later); pass the classifier to useProjectReadiness as classify, which is required from ui 0.73.0. How readiness answers are classified (isReadinessAnswer): A failure is treated as a readiness answer — meaning the page should wait and retry — when the error is any of:
  • A network failure (no status)
  • Any 5xx response
  • 403 project_access_required, 404 project_not_found, 408, 429
  • 409 project_not_provisioned specifically — a 409 with any other code (for example estimate_frozen) is not a readiness answer
Everything else — including 402 billing_read_only — is a refusal that retrying will not change. A refused error resolves as { kind: "refused" } so the hook does not retry it. The page displays it the same way it did before the readiness system existed. Project and estimate read (readProjectAndEstimate): Pages read both the project (api-global) and its estimate (api-estimate) together using Promise.allSettled. When both fail, the project’s failure takes precedence because api-global is the authority on whether the project exists. A fail-fast Promise.all could report whichever request lost the race — a deleted project could appear as “preparing” (the estimate’s 409) or a refused access as “unavailable”. Pages and surfaces covered: Inline variant for ProjectDocumentsPage (quietWhileLoading): The first readiness check on the Documents page is not worth a banner that appears and then immediately vanishes on every visit. quietWhileLoading maps the loading state to ready so ProjectPreparing draws nothing during the initial check. The live region stays mounted throughout, so a subsequent state change is announced without a remount. Retry key (readinessKey): A page that must re-read the same project (a Retry out of a refusal, a reload after a write) keys the useProjectReadiness hook with readinessKey(projectId, attempt), which returns "<projectId>#<attempt>". Incrementing attempt causes the hook to restart its read cycle.

In-place error display

Background reads that fail display their error inline beside the content they were loading, never as a toast. The design principle is: a background read the estimator did not initiate must not displace working content with a global toast notification. Affected surfaces:
  • Pricing panel rate data (WorkItemsPricingPanel) — a warning alert renders inline at the top of the panel when the background supplier-options load fails. Previously saved pricing remains visible. A Retry button lets the estimator request another load attempt.
  • Rate exceptions panel (RateExceptionsPanel) — when the exception rows fail to load, the panel shows the error message inline with a Retry exceptions button. Any previously loaded rows are kept visible; the error state only replaces them when rows is still null at the time of failure.

Error messages (ApiError and DocumentsApiError)

ApiError.message is user-facing text. It is the server’s own sentence when the response body carries a message field, or a plain sentence per HTTP status class when it does not. It never contains a status code, a URL, or a raw diagnostic composite. userFacingApiErrorMessage(status, body) in src/api.client.ts produces the value assigned to message. It returns serverErrorMessage(body) when a server message is present, and falls back to apiStatusSentence(status) otherwise. serverErrorMessage combines the body’s message and first issues[0].message into a single sentence. It trims both values and appends the issue text only when it differs from the top-level message. Field paths from issues[0].path are not included in message; they appear only in diagnostic. ApiError.diagnostic holds the old composite format ("API request failed with status N for <url> - ..."). Use it for logs and developer tooling. ApiError.serverMessage holds the raw server sentence before any issue appending, or undefined when the body carries no message. DocumentsApiError follows the same contract. StorageUploadError.message is always "The file could not be uploaded to storage. Try again." — S3 XML is kept only in StorageUploadError.responseText (truncated to 300 characters) and is never shown in the UI. The variation-submit fallback and S3 upload paths no longer include a status code in their visible error text. isConversionFailedError reads serverMessage (the typed field) rather than message text, so its detection is not affected by the message format change. apiStatusSentence maps HTTP status codes to plain sentences: Any surface that needs to show an error in the UI should read error.message. Any surface that logs or reports to a developer should use error.diagnostic.

friendlyErrorMessage

friendlyErrorMessage(error, fallback, verb?) in src/api.client.ts is the single translation point for surfacing any caught error to an estimator. It accepts a verb of "load" (default), "save", or "do" and returns:
  • Server fault (status ≥ 500, or a service_unavailable / internal_error code on any status): fixed wording — "We couldn't load this right now. Try again in a moment." — with a support reference appended when the response body carries a reference field. The verb determines "load this", "save this", or "do that". The server’s own message is suppressed entirely for these faults.
  • Client refusal (4xx without a fault code): the server’s own sentence, which is written for the person. error.message is returned directly.
  • Non-HTTP error (a dropped connection, a TypeError, or anything without a numeric status): the caller’s fallback.
Both ApiError and DocumentsApiError satisfy the structural duck-type check (instanceof Error with a numeric status), so friendlyErrorMessage works for both clients without an explicit import. Use friendlyErrorMessage in every catch block that renders to the UI. Pass "save" where the user asked to write data and "do" for actions that are neither a load nor a save (for example, submitting tender).

Documents shared utilities (documentsShared.tsx)

src/documentsShared.tsx contains shared helpers used across ProjectDocumentsPage and related document surfaces.

File type labels (fileTypeLabel)

fileTypeLabel(fileType, fileName) converts a stored MIME type to a human-readable description. Document cards call this to display the file type. It never shows a raw MIME string. Resolution order:
  1. Known MIME type mapped to a label (for example, "application/pdf" → "PDF", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" → "Excel spreadsheet").
  2. File extension, uppercased and suffixed with "file" (for example, "dwg" → "DWG file").
  3. "File" — when no extension is present or the extension is longer than five characters.
A null or undefined fileType is treated the same as an unrecognised type and falls through to the extension path.

Panel scrolling-region names (SectionPanelCard — D10)

SectionPanelCard in src/estimateShared.tsx wraps SectionCard from @stralos-co-nz/ui and forwards a scrollLabel prop to name the panel’s scrolling region for assistive technology. Without a label, every panel’s region was announced as just “List”. The wrapper defaults scrollLabel to the panel’s own title when title is a plain string and no explicit scrollLabel is passed. Panels with a non-string title (for example, a title that includes a Badge) should pass scrollLabel explicitly as a plain string. Pass scrollLabel explicitly only when the default title-based name would be ambiguous or incorrect. The scroll prop ("own" | "page") controls whether the panel owns its scroll region; only panels with scroll="own" (or without scroll) produce a named region — scroll="page" panels share their page’s scrolling element.

Live-region announcement pattern (AlertRegion)

AlertRegion (src/estimateShared.tsx) is an always-mounted assertive role="alert" region. It renders its children when there is content to show and collapses to position: absolute when empty, keeping the node in the accessibility tree at all times. The rule is: render the region unconditionally and put the condition on its children. A live region inserted into the DOM together with its content is not reliably announced by screen readers; a region that was already present and then receives content is. ModalError is built on AlertRegion. Any surface that needs a refusal or error announced — not only ModalError — should use AlertRegion directly when the message has more than one element to display. Do not nest one AlertRegion inside another; live regions are not composable in ARIA.

Surfaces that use AlertRegion directly

src/routes.ts carries four specifier-less imports (import "react", import "react-dom", import "react-router-dom", import "react/jsx-runtime"), one per import: false entry in .ci/shell-federation-contract.json. These imports prevent a race condition in @module-federation/vite 1.21.1’s export analysis. On a cold file cache, the plugin’s 10 ms settle timer can fire before all shared-package imports have been discovered, producing a virtual module with an incomplete export surface. Rolldown then fails with MISSING_EXPORT. A specifier-less import marks the share unsafe to narrow, so the plugin retains its full export surface regardless of what the scan managed to see. The cost is approximately 1 KB gzip per affected remote — within the D7 bundle growth budget. src/shareGuard.test.ts fails if these imports ever drift from the federation contract file.

Fill materials (src/fillMaterials.ts)

The estimate host loads the rates library’s Bulk Materials list and maps it onto the fill-material picker that appears in the reinstatement-layer modals (D73/D73b).

BulkFillMaterial model

BulkFillMaterial is the host’s own type. It carries both ends of the density range returned by api-rates: This type exists to keep both ends of the range alive beyond toUiFillMaterials. The ui contract (FillMaterialOption.density) is a single number, so toUiFillMaterials narrows the range to densityTPerM3Min before passing materials to the modal. The narrowing is lossless for items priced by one supplier; where the range is real, the work-item rate option on the line is what D73b designates as the density decision.

Omitted materials

fillMaterialsFrom omits a material only when api-rates can find no readable density for it. A material priced by multiple suppliers at different densities arrives with a minimum and maximum density and remains selectable. A page of rows in which not one carries densityTPerM3Min is treated as a load error rather than an honest empty list: that shape means the api-rates version predates the field, not that the organisation has no bulk materials.

Calculator assignment output seed (src/calculatorAssignments.ts)

seedOutputFor chooses the default outputKey for a freshly linked calculator layer, matching the API’s seedLinksForDefault exactly (D73b):
  • A bought fill layer (layer kind "reinstatement") that states looseM3 starts on looseM3 — the cubic metres actually delivered. Starting on inSituM3 would under-order by the full compaction factor.
  • A non-bulk reinstatement layer states only count, so seedOutputFor always resolves to count for that layer.
  • Every other layer (including excavation strata) starts on the first output the engine states, which is inSituM3 for an excavation stratum.
This rule is enforced on both manually added rows (newRow) and server-seeded defaults so the estimator sees the same starting output regardless of how the layer got linked.

Layer assignment control

LayerAssignmentControl (src/LayerAssignmentControl.tsx) implements the Used by column that each calculator editor renders per layer. The calculator UI (in @stralos-co-nz/calculator-ui) owns no fetch and knows no destination type — it takes a renderLayerAssignment(layer) slot and renders whatever node comes back inside that layer’s row. LayerAssignmentControl is the host half of that contract.

Assignability states

layerAssignability (from src/layerAssignment.ts) decides what the control renders for a given layer. The order is load-bearing: A picker is never shown for the no-outputs state. Rendering a picker an estimator can open and choose from, then be refused, is the defect this control exists to prevent.

Pending assignments (unsaved layers)

When an estimator chooses a work item for an unsaved layer, the choice is held in useLayerAssignments host state as a PendingAssignment. It is not written until the calculator is saved, because PUT /calculators/:id/links returns 422 calculator_link_unknown_layer for a layer the saved config does not yet hold. flushPendingAssignments runs after the config write and before the modal closes. It calls partitionPending to sort each pending choice into one of three buckets, then writes only the applicable set. partitionPending classifies each held choice against the post-save catalogue:
  • applicable — the layer exists and the choice can be written. This includes pending clears (destination null): a clear is applicable whenever the layer is still present, regardless of whether it currently emits any quantity. Applying a clear sends the layer with an empty row set, which marks it explicitly unassigned rather than re-seeding it from the calculator default.
  • noQuantity — the layer is saved but the engine emits no row (zero depth, or a bedding layer its pipe fully displaces). The choice is not applied and the estimator is told “that layer produces no quantity, so it was not assigned.” This message is correct for a pending assignment; for a pending clear the applicable path above applies instead, because the link the estimator asked to remove is still there.
  • missing — the layer was deleted before the save. The clear is dropped silently; no message is shown on a neighbour’s row. The layer’s links are gone with it, so “this feeds nothing” is already true.
applyPendingLinks assembles the full link register with every applicable held choice merged in. Before it builds the body it checks each pending layer for a split: if the layer was partitioned between the choice being made and the save, the flush refuses with SPLIT_LAYER_REFUSAL. Without this check, a flush on a partitioned layer would silently collapse every row to a single destination (or drop them all for a held clear), destroying a partition the estimator built with no undo. The assignment save is a register write, not a calculator save. It does not close the workbench and does not touch the unsaved draft.

layerPresence

layerPresence is the single shared decision for whether a layer is in the saved config. Both layerAssignability (at the cell) and partitionPending (at save, via flushPendingAssignments) call it. The two callers read the result differently: absent before a save means “just added”; after a save it means “deleted”. The function reports presence only — each caller names what that means in context.

Takeoff panel

TakeoffPanel (src/TakeoffPanel.tsx) is the estimate workspace’s Takeoff builder section. It hosts uploaded drawings and the measurement list. Takeoff usage read (useTakeoffUsage): TakeoffPanel owns one useTakeoffUsage call and passes the result to MapTakeoffSurface. The enabled parameter holds the first read until both the measurement list and the work-item list have loaded (detected by a stable refreshKey derived from both arrays). Without the gate, the hook fired once on mount and again as each list landed — three identical GET takeoff-links/usage calls per panel load. Map sheet measurements are memoised on the sheet id so navigating between pages does not re-derive them from scratch.

Map layers (src/councilUtilities.tsx, src/councilUtilitiesApi.ts, src/linzPropertyBoundaries.tsx)

CouncilUtilities is mounted on the map sheet surface (src/takeoffMapSurface.tsx) and owns the map’s Layers menu. The menu always offers LINZ property boundaries (LinzPropertyBoundaries), which is independent of any feature flag. The Utilities section, and every utility request, is gated by the PostHog boolean flag estimate-council-utilities (useFeatureFlag, falling back to enabled only on a localhost hostname, where the local stack runs without PostHog). The flag is a UI rollout control, not authorisation: the API routes apply normal authentication and organisation checks only (see Map takeoff). The menu is injected into the map toolbar via a React Portal targeting utilitiesToolbarRef (a stable <span> host). takeoffMapSurface.tsx places the host span (data-testid="utilities-toolbar-slot") in both the normal toolbar and the fullscreen header, and calls attachUtilitiesToolbar to move the same host DOM node between slots without remounting the menu controller.
  • Availability: with the flag on, each map idle (debounced 400 ms) calls GET /api/estimate/takeoff-map/layers with the viewport, clipped to New Zealand by utilityCoverageBounds. The response names the councils in view and the providers and layers registered for them. Refresh layer availability in the menu asks again.
  • Network and layer choice: each network (Stormwater, Wastewater, Drinking water) is a checkbox item. Expanding a network offers pipes and structures, and each provider layer, as exclusions. Choices persist while the viewport moves; a view with no matching layers says so and keeps them.
  • Loading: features load from GET /api/estimate/takeoff-utilities/features per selected provider layer, only when utilityViewAllowed passes (NZ envelope, at most 0.025° longitude by 0.02° latitude). A layer that fails is reported as incomplete coverage while successful layers stay visible. Loads abort when superseded or unmounted.
  • Detail panel: in the normal layout, an absolutely positioned overlay in the top-right of the map, visible while at least one network is on. Its collapsible Utility details body shows the selected feature’s provider, district, material, plan quantity, fetch and provider-loaded dates, source attributes, Save as measurement, Refresh utilities, and Coverage and sources (each provider’s coverage note and source link). In fullscreen the same body opens from a Utility details popover button in the header, and active providers’ attribution moves to a compact panel at the bottom left. Each active provider’s attribution and licence link stay visible while networks are on.
  • Selection: a map click hit-tests the loaded features only while the Select tool is active.
  • Saving: Save as measurement calls createGeoTakeoffMeasurement with a line (unit metre) or count (unit each), category Other, the label from utilityLabel, a quantity from geodesicQuantity over the whole returned part (no clipping to the view), and a sourceSnapshot from utilitySnapshot. The server stores its own geodesic quantity. The duplicate check (same provider, layer, object, and part already on the sheet) is UI-only, not a database uniqueness guarantee. Saving is disabled when the estimate is frozen.
  • Snapping: loaded utility vertices and lines, and property boundary lines, are passed to the map surface as snap candidates.
  • Provenance: SavedUtilitySource renders a saved measurement’s sourceSnapshot (provider, object, fetch and provider-loaded dates, attributes, attribution) in the measurement card. It is not flag-gated, so saved provenance stays readable if the flag is turned off. Snapshots saved before the provider catalogue still read as Canterbury source.
LinzPropertyBoundaries requests GET /api/estimate/takeoff-map/property-boundaries on map idle for a New Zealand view within the same size limit, and draws non-clickable dotted outlines below takeoff shapes. Route limits and errors are in the Estimate API reference. User-facing behaviour and coverage caveats are in the takeoff guide.

Drawing loupe flag (src/takeoffLoupeFlag.ts)

The drawing loupe is gated by the PostHog flag takeoff-loupe, read with useTakeoffLoupeEnabled() (fallback: false), so an unknown or unreachable PostHog shows no loupe. Only the loupe is gated: the snap ring, measuring and zoom are unchanged. With the flag off, TakeoffPanel.tsx skips the per-move loupe work entirely.

Snap attachments

A snapped point carries its attachment inside the measurement’s points jsonb, with no new column: { x, y, attach: { measurementId, vertex } } for a vertex, or { x, y, attach: { measurementId, segment, t } } for a line, where segment is the index of the segment’s first point (segmentPairs) and t is stored to six decimal places. Map points use lat/lng in place of x/y. The module is generic over a PointSpace, so drawings (pagePointSpace) and map sheets share the rules.
  • attachOf reads an attachment defensively and returns null for any malformed value. withAttach sets or clears one.
  • attachPosition resolves where an attachment sits on its host’s current shape. remapAttach keeps it valid after a host edit inserts or removes points; a midpoint insert splits the segment and the attachment moves to the half it was on.
  • followAttachments returns every measurement that must move because a host moved, transitively, with a step budget and a re-entry check against cycles. It runs over the whole page or sheet, not only the category on screen. withDraftFollower adds the unsaved draft under DRAFT_FOLLOWER_ID, so a paused draft follows in place and keeps its attachments for its own save.
  • TakeoffPanel.tsx and takeoffMapSurface.tsx save each follower through the same shape write a manual point edit uses, after the host. A follower that fails sets the follow error (the host’s save stands); a cancelled edit is skipped.
  • While a point is dragged, the dragged measurement, its followers and a following draft show the quantity their save will send (takeoffLiveQuantity.ts, measured with the same measureShape / measureDraft the save uses). Nothing is written until the drop.
  • Snap candidates from saved measurements carry their attach payload. Measurements being dragged are skipped as candidates, so a host cannot snap onto its own follower. On a map, utility and property boundary overlays are snap targets that never attach (takeoffMapGeometry.ts).
Lead defaults: dragging the attached point itself detaches it; a deleted host leaves its followers in place (an attachment to a measurement not on the page or sheet is never followed); attachments only link measurements on the same page or sheet. Server storage and copy behaviour are in Estimate service: snap attachments. User-facing behaviour is in the takeoff guide.

Measurement colours (takeoffMeasurementColour.ts)

Each saved takeoff measurement gets a stable colour derived from its id via FNV-1a hashing. The colour is used for both the list card and the mark on the drawing, so an estimator can visually match one to the other. The palette (MEASUREMENT_COLOUR_PALETTE) contains eight hues chosen to stand out on construction drawing backgrounds (mostly white, grey, and black line art). Red and teal are excluded: red reads as an error, and teal is reserved for draft measurements that have not yet been saved. Selection behaviour: selecting a measurement does not change its colour. A selected shape gains a halo (an rgba stroke underneath at lower opacity) and a heavier stroke width. The shape’s own stroke remains its palette colour throughout. This preserves the list-to-drawing colour match; a recolour to red on selection had broken it. measurementColour(measurementId) returns the hex colour. measurementColourRgba(measurementId, alpha) returns the same colour as an rgba() string, used for the filled halo on selected area shapes. Both functions are deterministic — same input, same output across sessions.

Programme Gantt layout

ProgrammePanel (src/ProgrammePanel.tsx) renders the estimate Programme tab using SVAR Gantt. Layout constants are defined in src/programmeGantt.ts and passed explicitly to the widget so the container height arithmetic never depends on SVAR defaults.

Layout constants

programmeChartHeight(rowCount, scaleRows, maxHeight) computes the container height as:
The 16 px scrollbar allowance prevents the container from being shorter than the chart content. The maxHeight ceiling preserves fixed-height behaviour for large programmes.

Date formatting

programmeStartCellText in src/ProgrammePanel.tsx formats the resting Start and End cells through formatDate from @stralos-co-nz/ui, producing 7 Sep 2026. The datepicker editor is unchanged. Date-only strings are treated as UTC to prevent a one-day shift west of UTC. The Start and Finish summary badges above the chart are also formatted through formatDate, so all date surfaces on the panel use a consistent format. The || "-" fallback preserves the empty-programme dash when formatDate returns an empty string for a missing or unparseable value.

Stale durations (EC-02)

A work item’s programme duration is stored independently and is not automatically updated when the activity durations on the estimate item change. ProgrammePanel computes staleDurations in a useMemo by comparing each header’s durationDays against workItemActivityDurationDays(workItem, hoursPerDay). A header is stale when the two values differ. When staleDurations is non-empty, the panel renders an Alert.Root warning above the Gantt listing each row with its current programme duration, the derived estimate-item duration, and the value that will be written. The Update durations button calls upsertProgrammeItem for each stale entry with durationDays set to the computed next value (rounded up to whole days). Start dates are unchanged. If the update fails, the error is surfaced inline below the list. The next value is the derived activity duration rounded up to the nearest whole day. workItemActivityDurationDays drives both the stale check and the update payload so the two cannot diverge.

Gantt saves and header name (deriveSavedName)

A managed programme header mirrors its work item’s name server-side. The panel caches the programme list, so after a work item is renamed elsewhere the cached header still carries the old name. Before this fix, every Gantt save — a date drag, a duration change, Update durations — sent the cached name back and overwrote the mirrored one. deriveSavedName(text, row, storedName) in src/ProgrammePanel.tsx resolves the name to include in a save payload. It returns undefined (omit from the request) unless the text in the row was genuinely edited by the user. The function treats three inputs as “not edited”:
  • No text at all.
  • Text equal to the display label the row is shown with (echoing back the rendered value).
  • Text equal to the stored name (no change from what the server already holds).
saveTask omits name from the upsert body when deriveSavedName returns undefined. updateStaleDurations (the Update durations action) never sends name. upsertProgrammeItem leaves name out of the body when absent. The API PUT takes every field as optional, so no server change was required. An explicit Gantt rename — the user types a new name in the Task cell — still sends the new name. The number prefix that SVAR prepends to the displayed label is stripped before comparison so that prefix echo is not mistaken for an edit.

Task action intercepts (add-task / delete-task)

initGantt registers two api.intercept handlers that gate the SVAR Gantt’s built-in row actions before they reach the server:
  • add-task — allowed only when the target row (the row the user triggered the add from) exists in itemsById and has type === "work_item". Any attempt to add a row at the top level (no target) or under a phase (sub_item) returns false and is silently blocked by SVAR. This means phases can only be created as direct children of a work-item header.
  • delete-task — allowed only when the row being deleted has type === "sub_item". Work-item headers are managed through the estimate item. Milestone rows carry runtime type === "milestone", which sits outside the shared ProgrammeItem union, and are blocked because the intercept permits only sub_item.
Both intercepts read from itemsByIdRef (a ref) rather than the closure, so they see the current programme state after every onProgrammeChanged refetch. When delete-task is allowed, the api.on("delete-task") handler opens a ConfirmActionModal before issuing the server delete via deleteProgrammeItem. SVAR removes the row from its own view optimistically before the event fires; confirming commits the delete to the server, and cancelling triggers onProgrammeChanged to restore the row SVAR already hid.

Select-time reveal guard

The bar-vanish defect that affects PlanGanttPanel also applies here. stepRevealGuard and shouldSuppressReveal in src/programmeGantt.ts provide the same single-shot suppression.

Pricing engine autosave (src/pricingEngine.tsx)

pricingEngine.tsx contains the estimate remote’s pricing hook. It autosaves work-item pricing to PATCH /work-items/:id/pricing after every user edit. The autosave is designed so that opening an estimate never writes anything.

Opening writes nothing

When an estimate loads, the server returns stored pricing rows for each work item. The client re-materialises those rows field by field using the keys it knows. The materialised result may spell values differently from the server’s stored row — a null the client omits, a key the server owns but the client has never heard of (such as resolvedAt on an auto-resolved entry), or a derived scalar — but the underlying data is unchanged. hydratedEntriesRef holds each stored entry exactly as hydration delivered it, by object identity. hydrationEchoRef tracks which component IDs are still echoing their stored entry. Before every flush computes a diff, it advances lastSavedPricingRef (the baseline) to the client’s own spelling of each echoed entry. That way, a re-materialised stored entry always matches the baseline and is never sent to the server. A user action that changes a stored entry calls dropSnapshot, which removes the entry from hydrationEchoRef permanently. From that point the component is live and its changes are diffed normally. Two asymmetries that previously caused spurious writes on open were fixed by this approach:
  • A null value the client omits: the client’s stablePricingPayload serialiser treats null as absent, so a row with a stored null and a re-materialised absent key looked different unless the echo baseline was advanced first.
  • A server-owned key such as resolvedAt: only keys listed in COMPARED_OVERRIDE_KEYS are compared. That Record is keyed by keyof WorkItemPricingComponentOverride, so a field added to the type cannot be omitted from comparison without a compile error.

Activity price choice (priceChoiceFor — D10)

priceChoiceFor in src/pricingEngine.tsx marks only components whose server-owned priceChoiceRequired flag is true. New activity lines use the project’s default option at insert time, including the project override, ordered rules and pricing location, project office, and cheapest eligible option. Several available options do not themselves require a choice. A persisted choice flag remains authoritative for legacy unresolved rows and lines the server could not price when inserted. Client re-resolution cannot clear it, even if a rule now matches: the server refuses an auto selection with 422 price_choice_required. Explicit selection or a server-confirmed resolution clears it. priceChoiceOptionCount and priceChoiceSuggestedOptionId supply the prompt’s count and suggestion. When the selector is closed on an unresolved line, it shows no price rather than an indicative or stale amount. collectPriceChoices (D10 companion) iterates every component the engine would resolve and builds a Map<componentId, PriceChoiceMark> for the work item render. The UI attaches a PriceChoicePrompt badge to the line, and shows a one-click Use <option> button when the server’s suggestion or a single project-location option is unambiguous.

Price choice attention (priceChoiceAttention.tsx — D10)

src/priceChoiceAttention.tsx provides the shared helpers for collecting and counting components with priceChoiceRequired === true for the notice, totals warning, and Submit tender block. The pricing resolver and zero-cost review logic also read the flag for their own guards. Exports:
  • priceChoiceLines(workItems) — returns every component with priceChoiceRequired === true, in schedule order, as PriceChoiceLine objects carrying workItemId, componentId, and a human-readable label ("1.2 Trench: Civil labour").
  • priceChoiceNotice(count) — the sentence every totals surface carries: "1 line needs a price choice; totals exclude it" (singular) or "N lines need a price choice; totals exclude them" (plural).
  • priceChoiceSubmitBlock(lines) — the reason Submit tender is refused while any lines are waiting. Returns null when nothing blocks. The block sentence names each waiting line by label so the estimator knows what to resolve.
  • PriceChoiceTotalsNotice — a React component that renders the notice beside a row of totals. Renders nothing when every line has a price.
A line waiting for a price choice has unitCost 0 as written by api-estimate; it contributes nothing to any total. needsZeroCostReview in src/workItemMoneyDisplay.ts returns false for a component with priceChoiceRequired === true so these $0 lines are not also flagged as zero-cost lines for review — the two notices are mutually exclusive. EstimatePage derives submitBlock from priceChoiceSubmitBlock(priceChoiceLines(workItems)) and passes it to EstimatePageHeader, which forwards it to LifecycleActionBar. The server enforces the same rule: POST /api/estimate/estimates/:id/transition (or the project-resolved twin) with action: "submit" returns 422 price_choice_required while any component on the estimate carries priceChoiceStatus === "choice_required".

Read model for price selections (priceSelection, valueOverrides)

Since PRD 211 F (stralos-feature-estimate#547), the pricing panel reads each build-up line’s stored selection directly from the component read model fields priceSelection and valueOverrides. It no longer rebuilds a pricing map from GET …/work-item-pricing; that endpoint is now read for the work item’s pricing source only. priceSelection carries the supplier name stored at selection time, the selected option’s location, and the selection context (supplier-scoped or organisation-scoped). The line’s source label is derived entirely from this stored value — it is not refreshed from the current rate library. valueOverrides is the server’s array of active hand-edited field values for the line (see Value overrides below). A line arriving without a priceSelection key — from a server build predating PR B of this stream — fails first-render resolution and renders as closed (the same state as a failed load). Removed: pricingMapsFromPersisted, toMaterialisedOverride, the sparse-patch baseline machinery for componentOverrides, and the dead synthetic-id activity flatten are all gone.

Work-item pricing PATCH (pricingSource only)

The autosave’s PATCH /work-items/:id/pricing now carries pricingSource alone. It no longer sends componentOverrides. Pricing selections are written through PUT …/pricing-rule (see below); the PATCH is only the signal that switches the work item between internal and supplier pricing.

Pricing rule write (PUT …/pricing-rule)

Rate option selections for a build-up line are written through PUT /work-items/:workItemId/components/:componentId/pricing-rule. The pricing panel calls this endpoint when the estimator chooses an option from the rate option picker. This replaces the previous componentOverrides path in the autosave PATCH.

Value overrides (PUT …/value-overrides)

Hand-edited field values (name, category, quantity, resource count, unit, unit cost) are written through PUT /work-items/:workItemId/components/:componentId/value-overrides. The request body sends only the fields the estimator changed. A reset sends { clear: true } which restores all fields on the line. src/valueOverrideMarks.ts owns the badge labels, tooltip text, and per-field format helpers used by both the build-up row (describeValueOverrides) and the component editor (describeValueOverride). The overridden fields are mapped through EDITOR_FIELD_OVERRIDE (camelCase editor keys → ValueOverrideField server keys). valueOverrideFor returns the active override for one field, or undefined.

Whole-return review hold

estimatePricingWrites.ts coordinates a write hold that the whole-return pricing review page takes while it is mounted. The review reads every returned rate against the stored snapshot, then applies a choice the server validates against that snapshot’s revision. No autosave may land between the read and the apply. The hold is a token set keyed by estimate ID. Releasing a token frees only that holder’s own slot. A remount — the page re-keyed to a different package, or React StrictMode’s double-invoke — runs the old cleanup before the new effect in the same commit. The release announcement is therefore deferred one microtask and suppressed when another holder acquires the estimate in that same turn. prepareEstimateVersionWrite checks the hold before a version save or restore. If the review is open it throws PRICING_REVIEW_OPEN_REFUSAL, which is shown inline. The same check runs in each mounted pricing editor so the refusal does not depend on any editor being mounted.

CI workflows

build-remote.yml runs on every push to main and every pull request. It rebuilds the pnpm workspace, runs type-check, lint, and tests, and builds the federation remote. It does not publish anything. deploy-dev.yml runs on every push to main and on manual dispatch. It produces the by-SHA artifact and synchronises the remote bundle to the dev remotes bucket. It does not activate the remote; stralos-core’s gate-dev workflow handles composition and activation. release.yml runs on a published GitHub release with a stable vX.Y.Z tag. It copies the already-produced by-SHA bundle to the semver prefix and writes the machine-readable release.json record (release-model R13). It never rebuilds. The step that copies bytes calls:
All six options are required. If any is absent, the script exits with a usage error that names the missing option. A release of a SHA requires that SHA to have been dev-deployed first; release.yml copies the by-SHA bundle and never rebuilds, so a tag on a commit that has no bundle fails before any write. See Micro-frontends — Dev publication for the full produce → gate → activate sequence.

Routes contract

build-remote.yml emits a routes-contract.json beside built-against.json inside the artifact (federation routes contract v2). The contract is code-only: remoteKey, contract, exposes, and routes[].path in code order (31 paths). Presentation metadata (nav labels, ordering, auth) stays in the shell/seed. The committed .ci/routes-contract.json is the source of truth. src/routes.contract.test.ts derives the contract from the real ./routes import (under vitest), asserts byte-identity with the committed file, and runs the vendored schema verifier. A route added, removed, or reordered fails the estimate remote’s own CI before the build step runs. The build then validates the committed contract against the vendored schema and copies it verbatim to dist-remote/routes-contract.json. build-remote.yml re-asserts the emitted artifact with the schema validator before upload, so a malformed or missing contract file never reaches the remotes bucket. The shell uses this published contract to discover the estimate remote’s routes without cloning the source repository at a git SHA (Stage 3 of the shell-reduction federation-routes-contract programme).