stralos-feature-activities is the Module Federation remote that owns the activity library and rate-sheet screens. It is registered in the shell as the activities remote and runs under contract v2, bundling its own copy of @stralos-co-nz/ui and @stralos-co-nz/domain.
Routes
Dependency pins
The remote bundles exact pinned copies of the shared design-system packages rather than declaring them as federation singletons. Current pins:
A version bump to any of these packages reaches this remote by updating the pin in
package.json and regenerating the one-member lockfile in .ci/workspace/pnpm-lock.yaml. No shell rebuild is required.
The pins carry these contracts that the remote depends on:
- ui 0.76.0 or later provides the guided tour runner (
registerTours) and thematch.pathconstraint that pins a tour to one route path. The Activities guided tour needs both. - ui 0.75.4 or later marks the selected row in
SelectComboboxandComboboxwitharia-selectedand a tick, and scrolls it into view when the list opens. The calculator substance, disposal material and reinstatement material dropdowns rely on this. - calculator-ui 0.10.3 or later turns on
pinHeldValuein the calculator material pickers. A saved choice that the list no longer offers (an inactive item, or a catalogue that failed to load) is listed first and marked as chosen, rather than missing from the open list. Picking that row changes nothing. - calculator-ui 0.11.0 or later accepts the
nonBulkMaterialscatalogue and exportsnonBulkMaterialsFrom. See Calculator material catalogues. - calculator-ui 0.14.0 or later depends on
@stralos-co-nz/calculators ^2.1.0, so thecalculatorspin moves with it. 0.14.1 draws a lying cylinder pit side on in the long section. - A pit structure that stands above the ground (a floor-referenced
structure-depthfit warning) shows as an orange warning. The full volume is still counted. See Advisory panel.
SideSheet non-modal mode beside the assistant
From ui 0.73.1,SideSheet opens non-modal from the md breakpoint upward when the AI assistant panel is open. Rather than overlaying the page, the side sheet renders beside the assistant so both remain usable at the same time. Below the md breakpoint the sheet still opens as a full-screen overlay.
Three surfaces in this remote use SideSheet:
- Activity component editor (
ActivityEditorModals) — the build-up line edit form. - Calculator assignments (
CalculatorAssignmentsDialog) — the assignment table for a library calculator. - Rate sheet detail (
RateSheets) — the selected sheet’s detail pane on wider viewports.
Scroll model and sticky list headers
The activities remote uses shared sticky-header patterns across its list surfaces. Each major list keeps its heading and column headers visible while the user scrolls.Activities list page (ActivitiesPage)
ActivitiesPage wraps the list in a SectionCard with stickyHeader. The card header holds the Activities PageHeader (title, count chip, and the Add activity button). A filters slot below the sticky band holds the search input. When rate sheets are enabled, a secondaryActions slot holds the rate-sheet selection controls (Select / New rate sheet).
DataTable (inside the sticky SectionCard) inherits the card’s --stralos-section-sticky-top offset so column headers park below the card header band rather than behind it. On the base breakpoint, the mobile InfiniteList card view replaces the table; the sticky card header still applies.
The expanded-activity rows table (ActivityTable inside each expanded row) also passes stickyHeader to its DataTable, so the component column headers stick while a long expanded row scrolls.
Rate sheets page (RateSheetsPage)
RateSheetsPage wraps the sheet list in a SectionCard with stickyHeader. The card header holds the Rate sheets PageHeader (title, back link to Activities, and the New rate sheet button). A filters slot holds the Saved sheets label and a Show archived / Show active toggle. The selected sheet’s detail section uses a separate SectionCard stickyHeader so its own header stays visible independently when both panes scroll.
Rate sheet refresh report (RateSheetRefreshDialog)
The refresh report section inside the refresh dialog uses SectionCard stickyHeader so the Refresh report title remains visible while the user reviews a long list of changed lines.
Rate sheet detail and package preview (RateSheets)
The package detail table and the new-sheet preview table each pass stickyHeader to their DataTable so column headers stay visible while reviewing a long package list.
New rate sheet package selection (CreateRateSheetPage)
The package search result list wraps its DataTable in a SectionCard stickyHeader. The header holds the title and the Continue with N packages action.
Activity rates build-up (ActivityRatesSection)
The included-rates and extra-overs grids each wrap their custom grid table inside a StickyTableScrollArea. The StickyTableScrollArea sets up a horizontally scrolling viewport while keeping the column header row (data-sticky-table-header="") pinned at the top of that viewport.
The three sub-sections of the build-up — included rates, extra overs, and production profiles — each wrap their content in a SectionCard stickyHeader so section titles stay visible as the page scrolls.
Production profiles (ProfilesEditor)
The production profiles table passes stickyHeader to its SectionCard. The header holds the section title and the Add profile action. The column header row inside the grid also uses data-sticky-table-header="" to stay visible when the profile list overflows.
Calculator assignments side sheet (CalculatorAssignmentsDialog)
The assignment table for each calculator section uses stickyHeader on its DataTable so calculator names stay visible inside the side sheet’s own scroll container.
List count lines
Each list shows a count line from@stralos-co-nz/ui that reads Filtered to N … while the list’s filter is on and Showing all N … otherwise. N is the server’s matching total, not the number of loaded rows.
On the Activities page the desktop table (
ActivityRows) and the base-breakpoint InfiniteList both read listFiltered, so they cannot disagree. The table passes no count at zero, so a settled empty list shows only its empty state.
Unit display in the activity editor
The activity editor, inline add, rates build-up and production-profile rows useUnitCombobox from @stralos-co-nz/ui, the same control as the rates editor. When a catalogue option has no explicit symbol, the closed field shows formatUnit(code) rather than the raw code.
Unit alias resolution at pricing time uses canonicaliseUnit from @stralos-co-nz/ui. The alias map is the shared unit catalogue in src/units/index.ts in stralos-ui, kept identical to the backend catalogue in @stralos-co-nz/api-common/units by a parity test. The local src/unitAliases.ts file has been removed.
Activity row labels
src/activityLabels.ts holds the words the activity list shows, for both the desktop ActivityTable and the mobile cards in ActivityRows.tsx.
activityColumnLabel(key)names each column. The desktop header and the mobile card’s label-value pair read the same entry, so an extra over’s cost is Total cost on both (the card used to say Extra cost). The+before the amount and the extra-overs heading already mark it as extra.componentKindLabel(component)names a component’s kind chip: Child activity for a component of typeactivity, otherwise Rate item. It matches the activity editor’s own Type picker. An included nested activity used to readchild activityas plain text while the same component as an extra over readactivityin a chip.ACTIVITY_KIND_LABEL(Activity) is only for an activity row’s own kind chip, never for a nested component.
Rate option source labels
rateOptionSourceLabel in src/activityComponentOptions.ts builds the “Company — location” label for a rate option. Library lines can no longer be pinned to a specific rate option — the component editor no longer shows a source picker. rateOptionSourceLabel is still used internally when computing activityRateOptionChoices to sort and label the options that inform the indicative cost range.
- Company: the trimmed
supplier.name, else Unknown supplier for a supplier option (scope === "supplier"or asupplierpresent), else the shell’s organisation name fromcurrentOrganisationName()insrc/session.ts, falling back to Our own prices. - Location:
location === nullmeans the price applies everywhere and shows All locations. Alocationwith a blank or nullnameis a real place whose name could not be resolved and shows Unnamed location, never All locations. There is no fallback to the retiredsourceLocationfield. - Sublabels and block reasons show unit symbols derived from
formatUnit— for example$40.70/m³rather than$40.70/cubic_metre. The count unit displays asea. Raw unit codes no longer appear in the source picker or block messages.
Calculator layer vocabulary
libraryCalculatorEditor.layerVocabulary is the single source of truth for which layers a stored calculator has, what kind each is, and which output keys each states. The assignment dialog (CalculatorAssignmentsDialog) and the Used by slot (LayerAssignmentControl) both build their lists from this vocabulary and never read the config’s own arrays directly. This matches what the API validates against, so the UI cannot offer a layer or output that the server would refuse.
Bulk-dig row exclusion
The engine emitspit-excavation and vol-excavation rows (the whole-hole excavation totals, labelled Bulk excavation) for pit and volume calculators. These rows are excluded from the vocabulary and are never offered in the assignment dialog or the Used by slot. The API does not link or insert them.
The overall Excavation volume reading (m:o:inSituM3) is still offered — it states the same total as an assignable measurement.
isBulkDigLayerId in libraryCalculatorEditor.ts identifies these rows and their layer-scoped measurement derivatives (m:l:pit-excavation:*, m:l:vol-excavation:*). collapseByLayer skips them when building the vocabulary.
Row names
Calculator row names come from the engine verbatim. They have no(<arm> calc) suffix, and the assignment dialog shows the engine’s names as-is.
Calculator material catalogues
useCalculatorCatalogues in src/calculatorRegistry.tsx loads four catalogues and passes them to the trench, pit and volume calculators, in both the modal and the full-page workbench: disposal substances, fill materials, disposal materials and non-bulk materials. Each hook loads once per mount. A failed load settles on { status: "error" } rather than throwing, so the calculator stays open and the layer’s saved choice remains authoritative. The estimator cannot pick a new material until the read succeeds.
Fill materials (useFillMaterials)
Reinstatement fill materials come from the Rates Library’s Bulk Materials rate group (quarry_materials), not from a separate settings catalogue. listBulkFillMaterials in src/api.ts pages /rate-summaries?rateGroup=quarry_materials to exhaustion. quarry_materials is a rate group token, never a category name.
fillMaterialsFrom in src/fillMaterials.ts maps the rows onto the FillMaterialsState contract:
- A row with a usable
densityTPerM3Minbecomes aFillMaterialOptionwithdensityMinanddensityMax. A row with no usable density is left out, because the option contract has no “unknown density” value. - A row that states a minimum but no maximum uses the minimum for both ends rather than inventing a wider range.
sortOrderfollows the page order from api-rates (category, then name, then id).- If rows arrive but none carries the
densityTPerM3Minkey, the api-rates build predates the field, so the loader reports{ status: "error" }instead of an empty list. A company with no bulk materials gets zero rows and an honest empty list.
Disposal materials (useDisposalMaterials)
The excavation stratum’s disposal material picker reads the disposal rate group and keeps only rows where item.rateGroup === "disposal" and item.status === "active". Density comes from the layer’s chosen substance, not from the disposal rate item.
Non-bulk materials (useNonBulkMaterials)
listNonBulkMaterials pages every rate summary, and nonBulkMaterialsFrom from @stralos-co-nz/calculator-ui keeps items in the other_materials and subcontractors rate groups. Each option carries the item’s modeUnit. Inactive and deleted items stay in the list as inactive, so a stored choice remains visible.
Trench config seeding (libraryCalculatorEditor)
normalizeTrenchCalculator in @stralos-co-nz/calculators dereferences both layers and pipes unconditionally, and api-activities answers 422 calculator_config_unreadable for a trench config without pipes. Configs written before pipe runs existed have no pipes key. seedFromStoredConfig in src/libraryCalculatorEditor.ts adds both arrays before normalising a stored config, so the calculator can open. toLibraryConfig adds them to every config this remote sends, on create and on every save. Each direction has its own test.
Stepped line cost display
A rate-item component whose item is priced by step carriesstepAmountRange (StepAmountRange: { min, max, basis: "step", stepCount }) from api-activities. Both ends are lump sums for the whole line.
api-rates cannot store a stepped rule yet (see Stepped base rules), so stepAmountRange stays null and this display is not reachable until it can.
CostRangeAmount in src/CostRangeAmount.tsx renders the step range only when the line has no per-unit range:
- With
stepCell="rate", the unit rate cell shows By step (STEP_RATE_TEXT). - With the default
stepCell="total", the total cell showsstepRangeText: whole dollars when both ends are whole ($250–$900), cents otherwise, and a single figure when every step costs the same. - The cell is one truncated line. Its
titleholds the full figure andSTEP_RANGE_TOOLTIP: “Priced by step. The estimate picks the step, so this line isn’t counted in the activity total yet.” - A stored
valuenever stands in for the step range.
ActivityRows and ActivityRatesSection pass component.stepAmountRange to both cells. The activity’s indicative total comes from api-activities, which adds nothing for such a line. See Stepped items.
Guided tour
The Activities page has a first-visit guided tour,activities.library, defined in src/tours/activities.tour.json. src/routes.ts registers it under the activities remote with registerTours. Its match names routeId: "activities" and path: "/activities", so it starts only on the list page, not on an activity, rate-sheet or calculator route.
The empty-state fallbacks explain that activities are built from rates in the Rates Library, for a company with no activities yet.
Anchors are declared in
TOUR_ANCHORS in src/tourAnchors.ts and placed as data-tour attributes. src/tours/tours.test.ts fails if the tour names an anchor missing from TOUR_ANCHORS, or if an entry in TOUR_ANCHORS is not placed anywhere in src.
No step is role-specific. Every signed-in person can add activities, so the add step and Add an activity suit admins and members alike.
Role gating
src/session.ts reads the signed-in role from localStorage["stralos.auth.user"]. The checks are advisory; api-activities enforces every write.
A member who reaches a rate-sheet selection control sees the disabled reason “Admin access required.”
Calculator list on the activity page
ActivityCalculatorsList (src/ActivityCalculatorsList.tsx) lists the calculators attached to one activity, above its build-up. ActivitiesPage reads them with GET /activity-calculators?activityId=, the same query the Calculator assignments side sheet uses, so both agree on what is attached. A load failure shows the error with Retry; an empty list shows No calculators yet. Use the Calculator button to add one.
Each row shows the name and type label (calculatorTypeLabel, for example “Trench calculator”) and an Open link whose href is the calculator’s own page. Admins and members both see the list. Remove is offered only when readCanEditActivities() is true (admin or member), matching api-activities’ requireOrgMember write gate.
confirmRemoveCalculator asks first (“It stays in your calculators and in any other activities that use it.” / “The items it added to this activity are removed.”). On confirm it:
- Re-reads the calculator with
getLibraryCalculator, so links to other activities are not lost. - Replaces the register with
replaceLibraryCalculatorLinks, keeping every link whoseactivityIdis not this activity. - Clears
defaultActivityIdwithupdateLibraryCalculatorwhen it points at this activity. - Re-reads the list, then the activity (
getActivity), because the reconcile removes the rows the calculator generated.
Feature flags
The rate-sheets feature is controlled byuseRateSheetsFlag. When the flag is off, the Rate sheets and Select controls do not appear on the Activities page, and the /activities/rate-sheets/* routes render nothing. The flag is read from rateSheetsFeatureFlag.tsx; its resolved state in the fixture suite is controlled by test/rateSheetsFlag.ts.
API client
All server communication goes throughsrc/api.ts, which calls the activities service (/api/activities) using the caller’s session bearer token. sameOriginBase.ts derives the base URL from window.location.origin; the local dev fallback is http://localhost:3302. Live rate-option prices are resolved through the shared rates service; src/rates/itemPriceRange.ts in the activities service resolves indicative ranges with a paged rate-summary sweep server-side.
Calculator config save errors
When a calculator config save is refused by the server,ActivityCalculatorPage routes the failure through calculatorConfigSaveFailure in src/errorText.ts before handing the message to calculator-ui. This converts ApiRequestError to a user-facing sentence — never the raw API URL or status line — and handles one named code:
The
calculator_config_backfill_from_ineligible code is raised when a reinstatement layer’s backfillFrom names an excavated stratum whose frozen substance has canUseForReinstatement: false. The page stays open and the config is not saved; the estimator must choose a different material for the reinstatement layer.
Disposal substance register
DisposalSubstance in src/api.ts includes an optional canUseForReinstatement field. This flag is served by api-global and indicates whether the substance’s excavated material is eligible as reinstatement fill. When a row arrives without the field, calculator-ui derives the flag from compactionPct; the host passes through whatever the API served without inventing a value. The flag is frozen into DisposalSubstanceSnapshot at selection time.
Rate option shape
listRateOptions reads a rate item’s options from /api/rates/rate-items/:rateItemId/options. The RateOption type in api.ts is the domain rate option with supplier and location narrowed to what api-rates actually sends: optional { id, name } references whose name is null when the name could not be resolved. A null location means the price covers every location. Pricing reads neither reference, so pricedOption strips both before calling the domain’s normalisedCostRate.
