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

# Dashboard frontend

> Frontend implementation of the Dashboard page in stralos-feature-dashboard.

`stralos-feature-dashboard` is the Module Federation remote that owns the `/dashboard` route. It provides a portfolio-level view across projects, with portfolio value tiles, a pipeline funnel with stage filtering, a closing-soon section, and a project table with full result-set virtual scrolling.

## Routes

| Path | Component | Notes |
| - | - | - |
| `/dashboard` | `DashboardPage` | Portfolio overview with portfolio value tiles, pipeline funnel with stage filtering, closing-soon section, win-rate window selector, and project table. |

The route is registered in `src/routes.ts` as a `RouteDefinition[]` exported as `dashboardRoutes`.

## Page structure

The dashboard page renders these sections:

1. **Header card** — `PageHeader` ("Dashboard", subtitle "Portfolio view across project estimates."), a `Toolbar` carrying the win-rate window selector (`SegmentedControl`), and `PortfolioSummary` rendering a `KpiStrip` with four compact `MetricTile`s: Pipeline value, Awaiting decision, Win rate by current value, Closing in the next 7 days.
2. **Pipeline** — Current non-archived project counts by lifecycle status rendered as `StatusBadge` chips in lifecycle order: Lead, Estimating, Submitted, Awarded, Active, Completed, Lost. Each chip also shows the combined current stored estimate value for projects at that stage when portfolio values are available.
3. **Closing in the next 7 days** — `ClosingSoon` component: a `SectionCard` listing projects with a target submission date in the next 7-day window, backed by `stats.portfolio.closingSoon`.
4. **Why tenders were lost**: `WhyWeLose` component, a `SectionCard` listing the window's lost tenders by reason. Rendered only when `stats.winRate.lostReasons` is present.
5. **Projects**: a `ReadOnlyDataTable` with six columns (Project, Client, Status, Location, Target submission, Proposed start), a `Toolbar` with an **Awaiting decision** / **All projects** `SegmentedControl`, and full result-set virtual scrolling.

### Win rate window selector

The win-rate window selector lives in a `Toolbar` above `PortfolioSummary`, not inside any tile. It renders as a `SegmentedControl` with `size="xs"`: **30d**, **90d** (default), **12m**. The label text "Win rate window" is a `Text` element associated via `aria-labelledby`.

Switching the window refetches stats without reloading the project list. While the refetch is in flight the previous response stays visible under its own echoed window label. The control reflects the new selection immediately.

### Portfolio tiles (`PortfolioSummary`)

`PortfolioSummary` in `src/PortfolioSummary.tsx` renders a `KpiStrip` with four compact `MetricTile`s. Values are sourced from `getPortfolioValues` and `stats.portfolio`.

| Tile | Source | Notes |
| - | - | - |
| **Pipeline value** | `sumPortfolioValues` over projects with status `lead`, `estimating`, or `submitted` | Excludes GST; groups by currency; shows coverage count. |
| **Awaiting decision** | `sumPortfolioValues` over projects with status `submitted` | Excludes GST; groups by currency; shows coverage count. |
| **Win rate by current value** | `valueWinRates` over `stats.winRate.outcomes` | Won value / (won + lost value) per currency; null percent when no decided value. |
| **Closing in the next 7 days** | `stats.portfolio.closingSoonCount` | Uses `localToday` and `deadlineThrough` for helper text. |

`PortfolioSummary` receives `{ stats, values, loading, unavailable }` from `DashboardPage`. When `loading` is true the tiles show "—" with "Loading values…"; when `unavailable` is true they show "—" with "Values unavailable". Multi-currency amounts group by currency code alphabetically and are formatted by `formatMoney`. `portfolioMoneyText` produces a plain-text rendering for pipeline chip labels.

`valueWinRates` derives per-currency win rates from the win-rate outcome list by summing `awarded` values against total decided values. The rate is `null` when total decided value is zero ("no data", not 0%).

### Portfolio values loading

`getPortfolioValues` in `src/api.ts` fetches current stored estimate values from `GET /api/estimate/estimates/portfolio-values`. It sends project IDs in batches of 100, running up to three batches concurrently. A failed batch rejects the whole call; `DashboardPage` catches this and sets `unavailable: true`. Values are keyed by `projectId` in the returned `Map<string, PortfolioValue>`.

`DashboardPage` caches values by project ID in `valueCacheRef`. The value effect runs on every stats response and requests only the IDs not yet in the cache, so a win-rate window switch over the same portfolio sends no request. A failed read caches nothing, so the next stats response retries it. Shown values are bound to the portfolio's sorted ID set (`portfolioKey`) and display only when `valueState.key` matches it, so a window switch keeps the tiles showing instead of blinking to loading. **Retry** replaces the cache with a new `Map` (not `clear()`, because a read still in flight writes into the map it started with) and reloads everything.

### Pipeline section

Funnel counts come from `GET /api/global/projects/stats`. If the fetch settles with no data, each chip renders an em-dash and the page shows "Stats unavailable — pipeline counts will appear once they can be read." The project table and other tiles are unaffected.

Each stage chip is a `Button` with `variant="plain"` that shows a `StatusBadge` and, when portfolio values are loaded, a `Text` below it with the combined value via `portfolioMoneyText`. Chips act as toggle filters: clicking a selected chip deselects it; clicking an unselected chip selects it. The **All stages** button is an outline `Button` that clears the filter. Active selection is indicated by `borderColor="text.interactive"`.

Calling `selectStage` invalidates any in-flight project list request via `requestIdRef` before updating `selectedStatus`, preventing stale appends from settling into a new filter.

### Closing soon section (`ClosingSoon`)

`ClosingSoon` in `src/PortfolioSummary.tsx` renders a `SectionCard` titled "Closing in the next 7 days". When `stats.portfolio` is available it shows:

* A helper line: the date window (`localToday` to `deadlineThrough`) and the total count.
* A `ReadOnlyDataTable` with columns: **Project** (link to `/projects/:id/estimate`), **Stage** (`StatusBadge` with `resolveLabel`), **Target submission** (`formatDate`).
* A footnote and **View all projects** button when `closingSoonCount > 10` (only the earliest 10 appear in `closingSoon`).

When `stats.portfolio` is null the section shows "Deadline information is unavailable." in muted text.

### Why tenders were lost (`WhyWeLose`)

`WhyWeLose` in `src/PortfolioSummary.tsx` renders a `SectionCard` titled "Why tenders were lost" and receives `{ stats }` from `DashboardPage`. It returns `null` when `stats?.winRate.lostReasons` is absent, so a Global API without the field leaves the dashboard unchanged.

When `lostReasons` is present:

* A helper line shows the total lost-tender count, the window, and "The reason chosen when each was marked lost".
* A `ReadOnlyDataTable` lists reasons with a non-zero count, sorted by count descending, then by label.
* Columns: **Reason** (`row.label`), **Lost** (`row.count`), **Share** (`formatPercent` of `count / total`, 0 fraction digits).
* The empty state reads "No tenders were lost in this window."
* `getRowId` uses `row.category`; `getRowLabel` uses `row.label`.

### Project table

A `Toolbar` above the table provides a `SegmentedControl` with two quick filters: **Awaiting decision** (sets `selectedStatus` to `"submitted"`) and **All projects** (clears the filter). The Projects card title reflects the active filter: "Awaiting decision" when `submitted` is selected, "Projects · \{stage label}" when another stage is selected via the pipeline funnel, or "Projects" when no filter is active.

Both dashboard tables are `ReadOnlyDataTable` (ui 0.78.0): neither has row actions or selection, so the remote does not bundle the kebab and checkbox code. The projects table uses `density="comfortable"`. A separate `ListCount` in the Projects card header states the match count (see [Project count](#project-count)). `getRowLabel` is set to `project.name` so each row has an accessible name without relying on `getRowId`.

The table header is sticky by default. The table uses `virtualData` with the server's `totalCount`, a `getItem(index)` lookup into the currently loaded window, and `onRangeChange(first, last)`. The page fetches an aligned window around the visible range when needed. This lets the reader scroll directly to any part of the result set without loading every preceding page. Only nearby rows are mounted.

The Projects `SectionCard` owns its own scroll box (the `SectionCard` default since ui 0.69.0). Scrolling through the project list moves only the list content; the rest of the page stays in place. The Pipeline and Closing in the next 7 days sections opt out with `scroll="page"` and scroll with the page.

The card passes `scrollLabel={projectsHeading}` so screen readers announce the scroll region by the visible heading ("Awaiting decision", "Projects · \[stage]", or "Projects") rather than the generic "List" fallback that `SectionCard` uses when no `title` prop is set.

Six columns, all from the API's own data:

| Column | Key | Notes |
| - | - | - |
| Project | `project` | Name as a `RouterLink` to `/projects/:id/estimate`; below it `projectReferenceLine` (job number or client reference, or null if it would repeat the name). |
| Client | `client` | Resolved from `clientNames` map (loaded via `listClients`). Unresolved: em-dash. |
| Status | `status` | `StatusBadge domain="project"` with label resolved from `project_statuses` setting list via `resolveProjectStatusLabel`. Colour comes from `statusPalette` in `@stralos-co-nz/ui`. |
| Location | `location` | `projectLocationLine(project.geoTag)` — city then region; empty string renders as em-dash. |
| Target submission | `targetSubmission` | `formatDate(project.targetSubmissionDate)`. |
| Proposed start | `startDate` | `formatDate(project.startDate)`. |

Row click calls `navigate(projectRowDestination(projectId))`. The result is discarded (`void navigate(...)`) because react-router 7's `navigate` returns a Promise and `onRowClick` expects void.

The currently loaded window is replaced when the visible range moves; `resetKey` resets the virtual list when the stage filter changes. From ui 0.78.2, a render loop or measuring error in the table falls back instead of crashing the page (see [Design library](https://help.stralos.app/internal/design-library.md#0782-virtual-list-safety-net)).

### Client name loading

`listClients` fetches one page of the companies register filtered by `role: "client"` (`GET /api/global/parties?role=client`). Page size is 100. Past the hundredth client a project shows an em-dash. Client load failure degrades silently: the catch block is empty, the column shows em-dashes, and the page error banner is never set.
A status with zero projects may be absent from the stats response; the funnel treats a missing key as zero.

### Project list error handling

When `listProjects` fails, an inline error banner with a **Retry** button appears inside the Projects card. The banner uses `status.error.fg` / `status.error.bg` tokens (no literal palette colours). Stats failures and client load failures never reach this banner.

### Setup empty state (`SetupEmptyState`)

While a company is still being set up, the project table's empty state points at the next setup step instead of the normal **No projects yet** prompt with its **New project** button.

This sits behind the `onboarding-setup-lock` PostHog flag, the same flag that gates the shell's [Projects setup lock](https://help.stralos.app/internal/frontend-architecture.md#projects-setup-lock). `DashboardPage` reads it with `useFeatureFlag("onboarding-setup-lock", { fallback: false })`. Until PostHog answers `true` (still loading, missing or failed), the setup status is not fetched and the normal empty state shows.

With the flag on, the page calls `GET /api/global/organisation-setup` and passes the answer through `incompleteSetup(value)` (`src/setupStatus.ts`). It returns `null`, meaning "use the normal empty state", unless the answer is an object with `complete: false`, `exempt` not `true`, a `nextStep` of `office`, `client` or `rate`, and a `steps` object. Any error from the request also leaves the normal empty state, so a failed setup check never hides **New project**.

`SetupEmptyState` renders:

* The title "Let's get you set up" and the description "Before your first project, please add these three things."
* The three steps in order: **Add your office**, **Add a client company**, **Add a rate to your Rates Library**. A done step shows a tick and "(done)".
* For an admin, one button for the next step: **Add your office** (`/locations`), **Add a client** (`/companies`) or **Add a rate** (`/rates`).
* For a member, the note "Your admin is still setting things up. Please ask them to add …", naming the next step ("your office", "a client company" or "some rates to the Rates Library").

The setup empty state replaces the empty state for every stage cut, because a company that has not finished setup has no projects. `readIsAdmin()` reads `role` from `stralos.auth.user` in `localStorage` and returns `false` when the key is absent or unreadable. It only chooses between the button and the note.

## Project identity helpers (`src/projectIdentity.ts`)

Two pure helpers replaced `projectSummaryLine`:

* `projectReferenceLine(project)` — returns the identifier (job number → client reference → name) or `null` when the identifier equals the name, preventing the "name printed twice" defect.
* `projectLocationLine(geoTag)` — returns city, region joined by `", "`, empty string when both are absent. The join is computed from the halves that survive rather than attached to one half.

`projectIdentifier(project)` is the shared fallback chain used by `projectReferenceLine`.

### Project count

`src/projectsListFooter.ts` exports `projectsCountIsMeaningful(total)`, which returns `true` only for a positive total. The Projects card displays `ListCount` in an always-mounted `aria-live="polite"` wrapper when this is true, and leaves the empty state to explain a zero total. It passes the server's `totalCount` as `total`, `noun={{ one: "project" }}`, and `filtered={selectedStatus !== null}`. Any cut, whether **Awaiting decision** or a pipeline stage, reads "Filtered to n projects"; only **All projects** reads "Showing all n projects". The count describes the whole matching result set, not the loaded window.

## API layer

### Runtime URL resolution

The dashboard resolves its Global API base URL through this chain (highest to lowest):

1. `VITE_STRALOS_GLOBAL_API_URL` — Vite environment variable.
2. `VITE_STRALOS_API_URL` — legacy Vite environment variable.
3. `window.__STRALOS_API_URLS__.global` — runtime seam the shell can inject.
4. `localStorage.getItem("stralos.globalApiUrl")` — per-module localStorage override for local development.
5. `sameOriginFallback("http://localhost:3300")` — in a production build returns the current `window.location.origin`; under Vite dev or in a Node test environment returns the localhost default.

### API endpoints

| Operation | Method | Path |
| - | - | - |
| List projects | `GET` | `/api/global/projects` |
| List projects (filtered) | `GET` | `/api/global/projects` with `status` param |
| Get project stats | `GET` | `/api/global/projects/stats` |
| List clients | `GET` | `/api/global/parties` with `role=client` |
| List setting list items | `GET` | `/api/global/setting-list-items` |
| Get portfolio values | `GET` | `/api/estimate/estimates/portfolio-values` |
| Get company setup status | `GET` | `/api/global/organisation-setup` (only with the `onboarding-setup-lock` flag on) |

`GET /api/global/projects/stats` accepts `window` (`30d`, `90d`, `12m`), `from`, and `to` query parameters. The response includes `countsByStatus`, `winRate` (with `won`, `lost`, `outcomes`, the individual decided-outcome identities within the window, and `lostReasons`, an array of `{ category, label, count }` grouping the window's lost outcomes by reason), and `portfolio` (all non-archived projects, plus a `closingSoon` array of up to ten projects in `lead` or `estimating` status with a submission deadline in the next seven days, and a `closingSoonCount` for the full count). Callers treat a failed fetch as stats unavailable rather than fatal.

`GET /api/estimate/estimates/portfolio-values` accepts a `projectIds` query parameter (comma-separated IDs). Returns `{ items: PortfolioValue[] }` where each item carries `projectId`, `estimateId`, `currency`, `tenderPrice`, and `basis: "current_stored_estimate"`. A failed fetch sets `unavailable: true` on the value state; tile values show "—" with "Values unavailable" and the rest of the page is unaffected.

`GET /api/global/organisation-setup` returns `{ complete, exempt, steps, nextStep }`. `steps` maps `office`, `client` and `rate` to booleans. `nextStep` is the first step still missing, in that order, or `null` when none is. `exempt` is `true` once the company has a live (non-archived) project, and an exempt company is always `complete`.

`GET /api/global/parties?role=client` replaces the old `/clients` endpoint (removed in migration 0049; the companies register now carries role filters).

### Authentication

The dashboard uses `authedFetch` from `src/api.ts`. On a 401, it attempts a single token refresh through `window.__STRALOS_AUTH__.refreshAccessToken` (the shell's refresh bridge) before retrying. When the bridge is absent (standalone development without the shell) the client falls back to re-reading the token from localStorage.

A second 401 means the session is unrecoverable. `authedFetch` calls `endDeadSession()` before redirecting to `/login?reason=expired&from=<encoded-path>`. `endDeadSession` prefers `window.__STRALOS_AUTH__.clearSession()` when the shell exposes it, and otherwise removes the three documented session keys (`stralos.auth.accessToken`, `stralos.auth.refreshToken`, `stralos.auth.user`) from `localStorage` directly. Clearing the session before the redirect is required: the shell's `/login` route treats a session as live on key presence alone and sends an authenticated visitor straight to the default route, so a redirect that leaves the dead keys in place loops back to the page that just failed.

### Busy retry (503 + Retry-After)

All GET requests pass through `fetchHonouringRetryAfter` before reaching `authedFetch`. When a GET returns 503 with a `Retry-After` header whose value is a whole number of seconds at or below 10, the client waits exactly that many seconds and retries the request once. The second response is returned as-is regardless of its status.

Two conditions prevent a retry:

* **Write methods** — POST, PUT, PATCH, and DELETE are never replayed. A 503 does not confirm the write did not happen, so replaying it risks a duplicate.
* **Bare 503** — a 503 without a `Retry-After` header, or with a value that is not a whole-number integer, or with a delay above 10 seconds, is returned immediately without retrying.

`busyRetryDelaySeconds` encodes this rule as a pure function exported from `src/api.ts` and is tested separately in `src/api.busy.test.ts`.

## Status labels and colours

Status labels come from the `project_statuses` platform-scoped setting list, resolved by `resolveProjectStatusLabel` from `@stralos-co-nz/domain`. The fallback when the list is loading or unavailable is a humanised form of the raw enum value.

Status badge colours come from the shared `statusPalette` map in `@stralos-co-nz/ui` via `StatusBadge domain="project"`. This remote no longer owns a per-feature colour map.

## Federation contract

The dashboard remote implements federation contract v2. The host singleton set is defined in `.ci/shell-federation-contract.json` (`stralos-shell/federation/v2`). That file is the single source of truth: `vite.config.ts` imports it and passes `contract.shared` to the federation plugin unchanged, and `scripts/federation-contract.mjs` derives the built-against sidecar, release record, and verifier from the same file.

Contract v2 shares only `react`, `react-dom`, `react-router-dom`, and `react/jsx-runtime`. `@stralos-co-nz/ui` and `@stralos-co-nz/domain` are **not** shared singletons. They are ordinary dependencies at exact pins in `package.json`, resolved from GitHub Packages by the one-member lockfile in `.ci/workspace/`, and bundled into the remote's artifact. A ui or domain release is no longer an estate event: bump the pin, regenerate `.ci/workspace/pnpm-lock.yaml`, and CI builds both.

Current pins: `@stralos-co-nz/ui` `0.78.2`, `@stralos-co-nz/domain` `3.10.0`.

## Routes contract v2

`src/routes.ts` exports `dashboardRoutes` and `RemoteProvider` side by side. The shell mounts `RemoteProvider` once as a pathless layout route around all of this remote's routes. `RemoteProvider` wraps `ui`'s `RemoteProvider` with `remoteKey: "dashboard"` (the federation name) and provides this remote's own Chakra scope. Every portalled overlay — dialogs, menus, toasts — re-attaches under that scope. This remote imports nothing from `@chakra-ui/react` directly.

## 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. A push to `main` produces the by-sha artifact but does not activate it on dev; making the new sha live is the job of stralos-core's gate-dev workflow. A manual dispatch follows the same produce path.

```sh theme={null}
# Produce from the current main tip (also runs automatically on merge)
gh workflow run deploy-dev.yml

# Produce main, asserting it is exactly this commit
gh workflow run deploy-dev.yml -f ref=<sha>

# Produce a specific branch or tag
gh workflow run deploy-dev.yml --ref <branch-or-tag> -f ref=<branch-or-tag>
```

`deploy-dev.yml` calls `build-remote.yml` via `workflow_call` with `deploy_dev: true`. That input enables PostHog chunk-id injection and retains source maps. It then runs two publishing jobs in order:

1. **`publish-by-sha`** — writes the write-once by-sha bundle to the durable artifact store at `remotes/dashboard/by-sha/<sha>/bundle.tar`.
2. **`publish-remote-dev`** — syncs the remote bundle to the remotes bucket at `remotes/dashboard/<sha>/`. It does **not** dispatch a manifest update or fold the sha into dev's live `remotes-manifest.json`; that step is gate-dev's responsibility.

Three consequences worth knowing:

* **Releasing a sha requires it to have been produced first.** `release.yml` copies the by-sha bundle to the semver prefix and never rebuilds. A release tag on a sha that was never produced fails when the by-sha object is not present.
* **Producing does not activate.** The sha is available in the artifact store and remotes bucket after `deploy-dev.yml` completes, but dev's live manifest is unchanged until gate-dev runs a compatible composition. Do not expect the dashboard to update on dev immediately after a merge.
* **A skipped publisher is not a produce.** Both publisher jobs gate on the build's `configured` output; a missing `WORKSPACE_CHECKOUT_TOKEN` fails `verify-ref` before the build. The final `deployed` job requires all upstream jobs to succeed, so a skipped dependency cannot produce a green run that wrote nothing.

Re-dispatching the same sha is safe in a narrow sense. `putWriteOnce` in `scripts/archive-remote.mjs` resumes on a 412 and re-adopts the stored object only when the bytes are identical. If they differ, the run fails loudly rather than silently replacing the stored bundle. Under contract v2, every build of the same commit produces the same bytes, so a re-dispatch of an unmodified sha always re-adopts.

### Source maps and PostHog instrumentation

`vite.config.ts` sets `build.sourcemap: "hidden"`. Vite emits one `.map` file per output chunk but does not append `//# sourceMappingURL` references to the JavaScript. The maps are present on disk but not loaded by browsers.

CI handles the maps differently depending on the event:

* **Produce (`deploy-dev.yml`)** — `@posthog/cli sourcemap inject` runs against `dist-remote/` before the artifact is uploaded. It rewrites each chunk in-place to embed a PostHog chunk ID, but does not upload the maps to PostHog at this stage. The maps are retained in the uploaded Actions artifact and in the by-sha bundle so they are available when a semver release is cut.
* **Build only (`build-remote.yml` on push or pull request)** — source maps are emitted but `deploy_dev` is false, so no injection or upload occurs.

### Federation contract and bundling

The dashboard uses federation contract v2 (`stralos-shell/federation/v2`). Under v2, `@stralos-co-nz/ui` and `@stralos-co-nz/domain` are ordinary dependencies at exact pins in `package.json`, resolved from GitHub Packages via the lockfile in `.ci/workspace/`, and bundled into the remote artifact. They are not shared singletons. Only `react`, `react-dom`, `react-router-dom`, and `react/jsx-runtime` are shared with the shell, each as an `import: false` singleton.

Current pins: `@stralos-co-nz/ui` `0.78.2`, `@stralos-co-nz/domain` `3.10.0`.

`scripts/resolve-singleton-sha.mjs` reads the contract, detects v2, logs the pinned versions, and resolves nothing at build time. Every build of the same commit produces the same bundle bytes. A re-dispatch re-adopts the stored object when the bytes match, or fails loudly — never a silent replacement.

To consume a new `@stralos-co-nz/ui` or `@stralos-co-nz/domain` export: bump the pin in `package.json` to a published version that has it and regenerate `.ci/workspace/pnpm-lock.yaml`. See `.ci/workspace/README.md` for how to regenerate the lockfile. Commit both in the same branch.

The dashboard declares `ROLLDOWN_WORKER_THREADS=1` to serialise graph traversal during the federation build.


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