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
The route is registered in
src/routes.ts as a RouteDefinition[] exported as dashboardRoutes.
Page structure
The dashboard page renders these sections:- Header card —
PageHeader(“Dashboard”, subtitle “Portfolio view across project estimates.”), aToolbarcarrying the win-rate window selector (SegmentedControl), andPortfolioSummaryrendering aKpiStripwith four compactMetricTiles: Pipeline value, Awaiting decision, Win rate by current value, Closing in the next 7 days. - Pipeline — Current non-archived project counts by lifecycle status rendered as
StatusBadgechips 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. - Closing in the next 7 days —
ClosingSooncomponent: aSectionCardlisting projects with a target submission date in the next 7-day window, backed bystats.portfolio.closingSoon. - Why tenders were lost:
WhyWeLosecomponent, aSectionCardlisting the window’s lost tenders by reason. Rendered only whenstats.winRate.lostReasonsis present. - Projects: a
ReadOnlyDataTablewith six columns (Project, Client, Status, Location, Target submission, Proposed start), aToolbarwith an Awaiting decision / All projectsSegmentedControl, and full result-set virtual scrolling.
Win rate window selector
The win-rate window selector lives in aToolbar 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 MetricTiles. Values are sourced from getPortfolioValues and stats.portfolio.
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 fromGET /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 (
localTodaytodeadlineThrough) and the total count. - A
ReadOnlyDataTablewith columns: Project (link to/projects/:id/estimate), Stage (StatusBadgewithresolveLabel), Target submission (formatDate). - A footnote and View all projects button when
closingSoonCount > 10(only the earliest 10 appear inclosingSoon).
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
ReadOnlyDataTablelists reasons with a non-zero count, sorted by count descending, then by label. - Columns: Reason (
row.label), Lost (row.count), Share (formatPercentofcount / total, 0 fraction digits). - The empty state reads “No tenders were lost in this window.”
getRowIdusesrow.category;getRowLabelusesrow.label.
Project table
AToolbar 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). 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:
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).
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
WhenlistProjects 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. 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”).
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) ornullwhen 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):VITE_STRALOS_GLOBAL_API_URL— Vite environment variable.VITE_STRALOS_API_URL— legacy Vite environment variable.window.__STRALOS_API_URLS__.global— runtime seam the shell can inject.localStorage.getItem("stralos.globalApiUrl")— per-module localStorage override for local development.sameOriginFallback("http://localhost:3300")— in a production build returns the currentwindow.location.origin; under Vite dev or in a Node test environment returns the localhost default.
API endpoints
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 usesauthedFetch 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 throughfetchHonouringRetryAfter 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-Afterheader, 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 theproject_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.
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:
publish-by-sha— writes the write-once by-sha bundle to the durable artifact store atremotes/dashboard/by-sha/<sha>/bundle.tar.publish-remote-dev— syncs the remote bundle to the remotes bucket atremotes/dashboard/<sha>/. It does not dispatch a manifest update or fold the sha into dev’s liveremotes-manifest.json; that step is gate-dev’s responsibility.
- Releasing a sha requires it to have been produced first.
release.ymlcopies 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.ymlcompletes, 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
configuredoutput; a missingWORKSPACE_CHECKOUT_TOKENfailsverify-refbefore the build. The finaldeployedjob requires all upstream jobs to succeed, so a skipped dependency cannot produce a green run that wrote nothing.
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 injectruns againstdist-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.ymlon push or pull request) — source maps are emitted butdeploy_devis 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.
