> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Activities frontend

> Internal architecture of stralos-feature-activities, the Module Federation remote for the activity and rate-sheet library.

`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

| Path | Component | Notes |
| - | - | - |
| `/activities` | `ActivitiesPage` | Activity library list. When no `activityId` is present, renders the paginated list with search and rate-sheet selection controls. |
| `/activities/:activityId` | `ActivitiesPage` | Activity detail page. Renders the edit form, production profiles, and rates build-up for one activity. |
| `/activities/rate-sheets` | `RateSheetsPage` | Rate sheet list and detail. The sheet detail renders beside the list on wider viewports. |
| `/activities/rate-sheets/new` | `NewRateSheetPage` | New rate sheet form with a package search and preview. |
| `/activities/rate-sheets/new/select` | `CreateRateSheetPage` | Package selection step when starting a sheet from the Rate sheets page rather than from the Activities list. |
| `/activities/rate-sheets/:rateSheetId` | `RateSheetsPage` | Brings up the selected sheet in the detail pane. |
| `/activities/calculators/:calculatorId` | `ActivityCalculatorPage` | Full-page calculator workbench for a library-level calculator. |
| `/activities/calculators/:calculatorId/used-by` | `ActivityCalculatorPage` | Used-by view for a library calculator; shows which activities and component rows it feeds. |

## Dependency pins

The remote bundles exact pinned copies of the shared design-system packages rather than declaring them as federation singletons. Current pins:

| Package | Version |
| - | - |
| `@stralos-co-nz/ui` | `0.78.2` |
| `@stralos-co-nz/calculator-ui` | `0.14.1` |
| `@stralos-co-nz/domain` | `3.9.0` |
| `@stralos-co-nz/calculators` | `2.1.0` |

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 the `match.path` constraint that pins a tour to one route path. The [Activities guided tour](#guided-tour) needs both.
* **ui 0.75.4 or later** marks the selected row in `SelectCombobox` and `Combobox` with `aria-selected` and 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 `pinHeldValue` in 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 `nonBulkMaterials` catalogue and exports `nonBulkMaterialsFrom`. See [Calculator material catalogues](#calculator-material-catalogues).
* **calculator-ui 0.14.0 or later** depends on `@stralos-co-nz/calculators ^2.1.0`, so the `calculators` pin 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-depth` fit warning) shows as an orange warning. The full volume is still counted. See [Advisory panel](https://help.stralos.app/internal/pit-calculator.md#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.

The shared SideSheet component supplies this behaviour.

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

| List | `filtered` when | Noun |
| - | - | - |
| Activities (`ActivitiesPage`) | The trimmed search is non-empty (`listFiltered`) | activity / activities |
| Rate sheets (`RateSheetsPage`) | The archived view is showing | rate sheet |
| New rate sheet package search (`NewRateSheetPage`) | The package search is non-empty | package |

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 use `UnitCombobox` 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 type `activity`, otherwise **Rate item**. It matches the activity editor's own **Type** picker. An included nested activity used to read `child activity` as plain text while the same component as an extra over read `activity` in 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 a `supplier` present), else the shell's organisation name from `currentOrganisationName()` in `src/session.ts`, falling back to **Our own prices**.
* **Location**: `location === null` means the price applies everywhere and shows **All locations**. A `location` with a blank or null `name` is a real place whose name could not be resolved and shows **Unnamed location**, never **All locations**. There is no fallback to the retired `sourceLocation` field.
* **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 as `ea`. 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 emits `pit-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 `densityTPerM3Min` becomes a `FillMaterialOption` with `densityMin` and `densityMax`. 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.
* `sortOrder` follows the page order from api-rates (category, then name, then id).
* If rows arrive but none carries the `densityTPerM3Min` key, 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 carries `stepAmountRange` (`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](https://help.stralos.app/internal/pricing-module.md#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 shows `stepRangeText`: 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 `title` holds the full figure and `STEP_RANGE_TOOLTIP`: "Priced by step. The estimate picks the step, so this line isn't counted in the activity total yet."
* A stored `value` never 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](https://help.stralos.app/api/activities.md#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.

| Step | Anchor | Notes |
| - | - | - |
| `welcome` | `activities.header` | Waits up to 5 s; skipped if missing. |
| `search` | `activities.search` | Skipped if missing. |
| `first-activity` | `activities.first-activity` | Waits up to 4 s, then falls back to `activities.empty-state`. |
| `cost` | `activities.first-cost` | Waits up to 4 s, then falls back to `activities.empty-state`. |
| `add` | `activities.add-activity` | Skipped if missing. |
| `finish` | Dialog | Offers **Add an activity** (navigates to `/activities/new`) or **Done**. |

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.

| Function | True when | Gates |
| - | - | - |
| `readCanEditActivities()` | Role is `admin` or `member` | Adding and editing activities, components, production profiles and calculator assignments |
| `readIsAdmin()` | Role is `admin` | Deleting an activity, rate-sheet create, refresh, duplicate and archive, and rate-sheet row selection |

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:

1. Re-reads the calculator with `getLibraryCalculator`, so links to other activities are not lost.
2. Replaces the register with `replaceLibraryCalculatorLinks`, keeping every link whose `activityId` is not this activity.
3. Clears `defaultActivityId` with `updateLibraryCalculator` when it points at this activity.
4. Re-reads the list, then the activity (`getActivity`), because the reconcile removes the rows the calculator generated.

The **Calculator assignments** side sheet also offers **Open** on each calculator row, through the same handler.

## Feature flags

The rate-sheets feature is controlled by `useRateSheetsFlag`. 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 through `src/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:

| Error code | Message shown |
| - | - |
| `calculator_config_backfill_from_ineligible` | "A reinstatement layer is filled from excavated material that can no longer be used for reinstatement — choose another material for it." |
| Any other `ApiRequestError` | The server's `detail` sentence, with `Reference: <ref>` appended when the server supplied one. Falls back to "The calculator could not be saved." if no detail was supplied. |
| `TypeError` (network failure) | "The calculator could not be saved." |
| Other `Error` authored by this remote | The error's own message, passed through as-is. |

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.