Skip to content

Account review

3 min read · Updated 2 Nov 2026

Account review is a complete Data App built around one decision: which account should the reader review next? Take its reasoning, not its layout or look. Another decision needs a different page, and another subject a different look, as Lead with a business story and Let the subject set the look explain.

Choice In this app Why
Layout A full-height queue beside one account, from Master–detail The reader works through items one at a time, so the list is the page’s main control
Hierarchy The account name, then a sentence, then a large trend beside a smaller breakdown The answer comes first, the evidence is sized by importance and there is no row of tiles
Palette A deep green rail on warm paper, one amber flag, and the green applied to both charts Colour marks what needs attention, and the charts belong to the page
Type Fraunces for headings, Roboto for text and charts, bundled from npm A display face gives the page a voice, and body and chart text match
Coverage Rank, orders, average order, a trend, a breakdown, a ranking and the largest orders It uses what the data offers, and each view answers a question the reader has
Takeaway A sentence built from the live row It stays true when the data or the selection changes
Motion One slow gradient behind the queue, stopped under reduced motion It is the ambient layer, and carries no data
Logo A simple glyph as the favicon, marked as a placeholder The logo and favicon rule applies when no logo is given

Accounts that have gone quiet carry a flag, and the page opens on the biggest one. Quiet is measured from the newest order in the list rather than from today, so historical data still reads correctly.

The queue and its figures follow the same filters as the charts, as Re-query your own objects describes, so a click on a chart never leaves a number unfiltered beside it. This is a desktop layout: the queue keeps a fixed width beside the review, and there is no narrow-screen arrangement.

You need a semantic layer where one measure can be broken down by an account, a category, a sales rep, an order and an order date, with order ID and order value fields for the derived figures. Replace the names in NAMES with your own, along with the page’s labels, and confirm the breakdowns with a bounded query first: a measure that cannot reach a dimension returns its total under an empty value rather than an error.

The measure must have kind: 'calculation'. The ranked charts use limiting.topN, which refuses a field of kind measure, and a chart compiled with kind measure reports an error when it mounts.

The queue and each chart look up their own fields while they prepare. If a name does not match, that object shows its error state with the missing name and the available names, and the rest of the page loads.

Install the fonts:

Terminal window
npm install @fontsource/fraunces @fontsource/roboto

In App Builder, a new app already has src/astrato.ts. Keep it, and run the install through Nash. Elsewhere, set up the app with the Data App quickstart first. An externally hosted app passes its own client instead of createAstratoApp.

import { createAstratoApp } from '@astrato-io/app-runtime';
import { createVisualizationRuntime, registry } from '@astrato-io/visualizations';
import { createVisualizationAvailableFields } from '@astrato-io/visualizations/definitions';
export { createDefaultVSpec } from '@astrato-io/visualizations';
export { compileVisualizationDefinition } from '@astrato-io/visualizations/definitions';
// One connection per app. Load engine metadata before mounting native visualizations.
export async function initializeAstrato() {
const app = await createAstratoApp();
try {
const getFieldMetadata = app.client.services.engine.fields.getFieldMetadata;
if (!getFieldMetadata) throw new Error('Astrato field metadata is unavailable.');
const metadata = await getFieldMetadata({ ...app.dataBinding });
if (!metadata) throw new Error('Astrato field metadata is unavailable.');
const availableFields = createVisualizationAvailableFields(metadata);
const runtime = createVisualizationRuntime({ client: app.client, registry });
return { app, runtime, availableFields };
} catch (error) {
app.destroy();
throw error;
}
}
export type AppContext = Awaited<ReturnType<typeof initializeAstrato>>;
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { initializeAstrato } from './astrato';
import '@astrato-io/visualizations/styles.css';
import '@fontsource/fraunces/latin-600.css';
import '@fontsource/roboto/latin-400.css';
import '@fontsource/roboto/latin-500.css';
import './styles.css';
// A placeholder mark: replace it with the logo of the thing this app is about.
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.href = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#b4532a"/><path d="M8 22V10l8 7 8-7v12" fill="none" stroke="#fff" stroke-width="3"/></svg>',
)}`;
document.head.append(favicon);
const root = createRoot(document.getElementById('root')!);
// Charts read their font when they draw, so it has to be ready before they mount.
const fonts = Promise.all([document.fonts.load('12px Roboto'), document.fonts.load('600 40px Fraunces')]).catch(
() => undefined,
);
Promise.all([initializeAstrato(), fonts]).then(
([astrato]) => root.render(<App astrato={astrato} />),
(error) => {
console.error(error);
root.render(<main role="alert">Could not load this app's data.</main>);
},
);
import type {
AtomicInputVisualizationDefinition,
VisualizationDefinition,
} from '@astrato-io/visualizations/definitions';
import { applyOperations } from '@astrato-io/visualizations/operations';
import { compileVisualizationDefinition, createDefaultVSpec, type AppContext } from './astrato';
/** Names as the layer's metadata reports them. Replace them with your own. */
const NAMES = {
account: 'Customer',
category: 'Category',
owner: 'Sales Rep',
revenue: 'Net Revenue',
orderView: 'Orders',
orderDate: 'Order Date',
orderId: 'Order ID',
orderValue: 'Order Value',
};
export const GROUP = 'review';
const LOCALE = 'en-GB';
/** An account with no order this close to the newest one listed counts as gone quiet. */
const QUIET_AFTER_DAYS = 60;
const DAY = 86_400_000;
export const money = (value: number) =>
new Intl.NumberFormat(LOCALE, { notation: 'compact', maximumFractionDigits: 1 }).format(value);
/** Read in UTC, so a date-only value keeps its calendar day in every timezone. */
export const longDate = (date: Date) =>
new Intl.DateTimeFormat(LOCALE, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(date);
type AvailableField = AppContext['availableFields'][number];
export type Selections = AppContext['app']['client']['services']['selections'];
export type Selection = ReturnType<Selections['getSelection']>['applied'][number];
/** Carries the missing name and the available names, so an object's error state can show them. */
class MissingFieldError extends Error {}
export const fieldProblem = (error: unknown) => (error instanceof MissingFieldError ? error.message : null);
/** Throws with the available names, so a renamed field never reads as an empty chart. */
function findField<Kind extends AvailableField['kind']>(
context: AppContext,
name: string,
kinds: Kind[],
view?: string,
) {
const matches = context.availableFields.filter(
(field): field is Extract<AvailableField, { kind: Kind }> =>
field.name === name &&
(kinds as string[]).includes(field.kind) &&
(view === undefined || ('view' in field && field.view === view)),
);
const [field] = matches;
if (matches.length !== 1 || !field) {
const names = context.availableFields.map((candidate) => `${candidate.name} (${candidate.kind})`).join(', ');
throw new MissingFieldError(`Expected one ${kinds.join(' or ')} named ${name}. Available names: ${names}`);
}
return field;
}
/** Selections name a stored dimension by the view column it is defined on. */
function selectionKeyOf(context: AppContext, field: Extract<AvailableField, { kind: 'dimension' }>) {
const { view, name } = field.item.dimension;
const column = context.availableFields.find(
(candidate) => candidate.kind === 'field' && candidate.view === view && candidate.name === name,
);
if (!column) throw new MissingFieldError(`No view column found for ${field.name}.`);
return column.fieldKey;
}
/** A calculation: charts cannot rank by a field of kind `measure`, or mount one from a definition. */
const findRevenue = (context: AppContext) => findField(context, NAMES.revenue, ['calculation']);
const findOrderField = (context: AppContext, name: string) => findField(context, name, ['field'], NAMES.orderView);
/** Only what the queue needs: each chart looks up its own fields while it prepares. */
export function resolveQueueFields(context: AppContext) {
const account = findField(context, NAMES.account, ['dimension']);
return {
account,
accountSelectionKey: selectionKeyOf(context, account),
revenue: findRevenue(context),
orderDate: findOrderField(context, NAMES.orderDate),
orderId: findOrderField(context, NAMES.orderId),
orderValue: findOrderField(context, NAMES.orderValue),
};
}
export type QueueFields = ReturnType<typeof resolveQueueFields>;
export interface Account {
name: string;
rank: number;
revenue: number;
orders: number;
averageOrder: number;
lastOrder: Date | null;
quietDays: number | null;
}
/**
* The applied selections as query filters, the way the charts apply them. A chart on a grouped date
* also keeps its level on the selection. The picked account stays out: it chooses a row of the
* queue, and as a filter it would remove the rest.
*/
export const accountFilters = (fields: QueueFields, applied: Selection[]) =>
(applied as (Selection & { dateLevel?: string; datePart?: string })[])
.filter(({ field, kind }) => field.fieldKey !== fields.accountSelectionKey && kind !== 'ASSOCIATIVE')
.map(({ field, operator = 'inList', dateLevel, datePart, values }) => ({
fieldKey: field.fieldKey,
operator,
...(dateLevel ? { dateLevel } : {}),
...(datePart ? { datePart } : {}),
values: values.map((value) => ({ dataType: 'string', valueString: String(value) })),
}));
/** The queue: the biggest accounts, with what the engine can derive about each of them. */
export async function loadAccounts(
{ app }: AppContext,
fields: QueueFields,
filters: ReturnType<typeof accountFilters>,
): Promise<Account[]> {
const query = {
dimensions: [{ itemKey: fields.account.item.key }],
calculations: [
{ itemKey: fields.revenue.item.key },
{ calculation: '{m1}', measures: [{ fieldKeys: [fields.orderDate.fieldKey], aggregation: 'MAX' }] },
{
calculation: '{m1}',
measures: [{ fieldKeys: [fields.orderId.fieldKey], aggregation: 'COUNT', distinct: true }],
},
{
calculation: '{m1} / {m2}',
measures: [
{ fieldKeys: [fields.orderValue.fieldKey], aggregation: 'SUM' },
{ fieldKeys: [fields.orderId.fieldKey], aggregation: 'COUNT', distinct: true },
],
},
],
filters,
orders: [{ columnIdx: 1, direction: 'desc' }],
limit: 14,
};
const result = await app.client.services.engine.getData({
semanticLayerId: app.dataBinding.semanticLayerId,
query,
});
const rows = Object.values((result.response as { data: Record<string, unknown[]> }).data);
const accounts = rows.map(([name, revenue, lastOrder, orders, averageOrder], index) => ({
name: String(name),
rank: index + 1,
revenue: Number(revenue),
orders: Number(orders),
averageOrder: Number(averageOrder),
// A date column can arrive as epoch milliseconds or as an ISO string, or be empty.
lastOrder: lastOrder === null ? null : new Date(lastOrder as string | number),
}));
// Anchored on the data, not on today: a historical extract would otherwise read as all quiet.
const newest = Math.max(...accounts.flatMap(({ lastOrder }) => lastOrder?.getTime() ?? []));
return accounts.map((account) => ({
...account,
quietDays: account.lastOrder && Math.round((newest - account.lastOrder.getTime()) / DAY),
}));
}
export const isQuiet = ({ quietDays }: Account) => quietDays !== null && quietDays >= QUIET_AFTER_DAYS;
/** The takeaway is built from the live row, so it stays true when the data or selection changes. */
export function takeaway(account: Account): string {
if (!account.lastOrder) return 'No order date is recorded for this account.';
const when = longDate(account.lastOrder);
if (account.quietDays === 0) return `Last ordered on ${when}, the newest order in this list.`;
return `Last ordered on ${when}, ${account.quietDays} days before the newest order in this list.`;
}
const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
/** A refused setting costs only itself, and the log is how you find it. */
const logRefused = (id: string, results: unknown) => {
const refused = Object.values(results as Record<string, { success: boolean }>).filter(({ success }) => !success);
if (refused.length > 0) console.error(`[${id}] mounted without some settings`, JSON.stringify(refused));
};
/** Compiles a definition and finishes it in memory, so it opens with the app's palette. */
async function prepare(
context: AppContext,
id: string,
// Field lookups run in here, so a missing field stays on this object.
buildDefinition: () => Exclude<VisualizationDefinition, AtomicInputVisualizationDefinition>,
operations: (ref: (name: string) => { dataItemInternalKey: string; dataItemUniqueSignature: string }) => {
name: string;
payload: unknown;
}[],
) {
const definition = buildDefinition();
const compilation = await compileVisualizationDefinition({
definition,
availableFields: context.availableFields,
baseVSpec: createDefaultVSpec(definition.type),
});
if (!compilation.success) throw new Error(`Definition refused: ${JSON.stringify(compilation.issues)}`);
// Operation payloads accept only these two keys of a resolved ref.
const ref = (name: string) => {
const { dataItemInternalKey, dataItemUniqueSignature } = compilation.resolvedRefs[name]!;
return { dataItemInternalKey, dataItemUniqueSignature };
};
const finished = await applyOperations({
instance: { id, type: definition.type, vSpec: compilation.vSpec },
operationRequests: operations(ref).map((operation, index) => ({ id: `step-${index}`, ...operation })),
});
logRefused(id, finished.results);
return { id, type: definition.type, vSpec: finished.vSpec };
}
const quietAxes = () => [
{ name: 'setAxisLabelColor', payload: { axisName: 'dimension', labelColor: token('--muted') } },
{ name: 'setAxisLabelColor', payload: { axisName: 'primaryMeasure', labelColor: token('--muted') } },
{ name: 'setAxisLineColor', payload: { axisName: 'dimension', lineColor: token('--line') } },
];
export const monthlyRevenue = (context: AppContext) =>
prepare(
context,
'monthly-revenue',
() => ({
version: 1,
type: 'line',
data: {
dimensions: [{ ref: 'month', fieldKey: findOrderField(context, NAMES.orderDate).fieldKey }],
measures: [{ ref: 'revenue', fieldKey: findRevenue(context).fieldKey, dataPointShape: 'circle' }],
},
presentation: { legend: { visible: false }, dataLabels: { visible: false } },
sorting: [{ by: 'month', direction: 'asc' }],
}),
(ref) => [
{ name: 'setDimensionTemporalGrouping', payload: { ...ref('month'), mode: 'level', dateLevel: 'month' } },
{ name: 'setLineType', payload: 'straight' },
{ name: 'setMeasureGraphColor', payload: { ...ref('revenue'), graphColor: token('--series-1') } },
...quietAxes(),
],
);
/** A ranked bar with its values printed on it, so it needs no value axis. */
const rankedBars = (context: AppContext, id: string, findGroup: () => { fieldKey: string }, color: string) =>
prepare(
context,
id,
() => ({
version: 1,
type: 'bar',
data: {
dimensions: [{ ref: 'group', fieldKey: findGroup().fieldKey }],
measures: [
{
ref: 'revenue',
fieldKey: findRevenue(context).fieldKey,
dataLabels: { visible: true, placements: ['outside', 'inside'] },
},
],
},
limiting: { topN: { by: 'revenue', count: 6, direction: 'desc', showOthers: false } },
presentation: {
orientation: 'horizontal',
legend: { visible: false },
axes: { primaryMeasure: { labelsVisible: false } },
},
sorting: [{ by: 'revenue', direction: 'desc' }],
}),
(ref) => [
{ name: 'setMeasureGraphColor', payload: { ...ref('revenue'), graphColor: color } },
{ name: 'setBarChartBarRadius', payload: 2 },
...quietAxes(),
],
);
export const revenueByCategory = (context: AppContext) =>
rankedBars(
context,
'revenue-by-category',
() => findField(context, NAMES.category, ['dimension']),
token('--series-1'),
);
export const revenueByOwner = (context: AppContext) =>
rankedBars(
context,
'revenue-by-owner',
() => findField(context, NAMES.owner, ['dimension', 'field']),
token('--series-2'),
);
export const largestOrders = (context: AppContext) =>
rankedBars(context, 'largest-orders', () => findOrderField(context, NAMES.orderId), token('--series-1'));
import React, { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import type { AppContext } from './astrato';
import {
accountFilters,
fieldProblem,
GROUP,
isQuiet,
loadAccounts,
longDate,
money,
largestOrders,
monthlyRevenue,
resolveQueueFields,
revenueByCategory,
revenueByOwner,
takeaway,
type Account,
type QueueFields,
type Selection,
type Selections,
} from './review';
type ObjectState = 'loading' | 'ready' | 'error';
type Prepared = Awaited<ReturnType<typeof monthlyRevenue>>;
/** Mounts one prepared object. The chain orders mount against unmount; see Mounting objects. */
function useObject(context: AppContext, prepare: () => Promise<Prepared>) {
const host = useRef<HTMLDivElement>(null);
const lifecycle = useRef<Promise<void>>(Promise.resolve());
const [state, setState] = useState<ObjectState>('loading');
const [problem, setProblem] = useState<string | null>(null);
useEffect(() => {
const { app, runtime } = context;
let disposed = false;
let unmount: (() => void) | undefined;
lifecycle.current = lifecycle.current
.catch(() => undefined)
.then(async () => {
if (disposed || !host.current) return;
const { id, type, vSpec } = await prepare();
runtime.createInstance({
id,
type,
groupId: GROUP,
vSpec,
dataBinding: { ...app.dataBinding, queryContext: { objectId: id } },
});
const handle = await runtime.mount(host.current, id);
unmount = () => {
handle.unmount();
runtime.removeInstance(id);
};
if (disposed) unmount();
// Do not await readiness in this chain, or it delays the next mount or unmount.
void handle.ready.then(({ status }) => {
if (disposed || status === 'unmounted') return;
// A hidden tab can time out before it draws; the chart stays mounted and draws later.
setState(status === 'error' || (status === 'timeout' && !document.hidden) ? 'error' : 'ready');
});
})
.catch((error) => {
console.error(error);
if (disposed) return;
setProblem(fieldProblem(error));
setState('error');
});
return () => {
disposed = true;
lifecycle.current = lifecycle.current.then(() => unmount?.());
};
}, [context]);
return { host, state, problem };
}
function Panel({
title,
subtitle,
state,
problem,
host,
className,
}: {
title: string;
subtitle: ReactNode;
state: ObjectState;
problem: string | null;
host: React.RefObject<HTMLDivElement | null>;
className: string;
}) {
return (
<figure className={`panel ${className}`} data-state={state} aria-busy={state === 'loading'}>
<figcaption>
<h2>{title}</h2>
<p>{subtitle}</p>
</figcaption>
<div className="panel-host" ref={host} />
<p className="panel-state">
{state === 'error' ? 'This chart could not be loaded.' : null}
{problem ? <small>{problem}</small> : null}
</p>
</figure>
);
}
type SelectionState = ReturnType<Selections['getSelection']>;
/** Follows the group, so the page and the charts always agree on what is selected. */
function useSelections(selections: Selections) {
const [state, setState] = useState<SelectionState>(() => selections.getSelection(GROUP));
useEffect(() => {
const subscription = selections.onActiveSelectionChange(GROUP).subscribe(setState);
return () => subscription.unsubscribe();
}, [selections]);
return state;
}
/** The queue follows the applied filters as the charts do, and ignores an answer that arrives late. */
function useAccounts(context: AppContext, fields: QueueFields | null, onOpen: (accounts: Account[]) => void) {
const [accounts, setAccounts] = useState<Account[] | null>(null);
const [busy, setBusy] = useState(fields !== null);
const [failed, setFailed] = useState(fields === null);
useEffect(() => {
if (!fields) return;
let latest = 0;
let signature: string | undefined;
const refresh = (applied: Selection[]) => {
const filters = accountFilters(fields, applied);
const next = JSON.stringify(filters);
// Picking an account and repeated emissions leave the filters as they were.
if (next === signature) return;
const opening = signature === undefined;
signature = next;
const request = ++latest;
setBusy(true);
loadAccounts(context, fields, filters).then(
(rows) => {
if (request !== latest) return;
if (opening) onOpen(rows);
setAccounts(rows);
setFailed(false);
setBusy(false);
},
(error) => {
console.error(error);
if (request !== latest) return;
setFailed(true);
setBusy(false);
},
);
};
// This replays the current state on subscribe, which also loads the first list.
const subscription = context.app.client.services.selections
.onActiveSelectionChangeApplied(GROUP)
.subscribe(({ applied }) => refresh(applied));
return () => {
latest += 1;
subscription.unsubscribe();
};
}, [context, fields]);
return { accounts, busy, failed };
}
/** Readers see what is filtered. A chart click stays pending until they apply it. */
function SelectionBar({
context,
fields,
selections,
state,
}: {
context: AppContext;
fields: QueueFields | null;
selections: Selections;
state: SelectionState;
}) {
const label = (fieldKey: string) =>
fieldKey === fields?.accountSelectionKey
? fields.account.name
: (context.availableFields.find((field) => field.fieldKey === fieldKey)?.name ?? 'Selection');
const byField = new Map([...state.applied, ...state.pending].map((entry) => [entry.field.fieldKey, entry]));
return (
<div className="selections" role="region" aria-label="Selections">
{[...byField.values()].map((entry) => (
<span className="chip" key={entry.field.fieldKey}>
{label(entry.field.fieldKey)}: {entry.values.join(', ')}
<button
type="button"
aria-label={`Remove ${label(entry.field.fieldKey)}`}
onClick={() => selections.removeSelection(GROUP, entry)}
>
×
</button>
</span>
))}
{state.pending.length > 0 ? (
<>
<button type="button" onClick={() => selections.applyPendingSelections(GROUP)}>
Apply
</button>
<button type="button" onClick={() => selections.clearPendingSelections(GROUP)}>
Discard
</button>
</>
) : null}
</div>
);
}
function Queue({
accounts,
busy,
failed,
problem,
selected,
onPick,
}: {
accounts: Account[] | null;
busy: boolean;
failed: boolean;
problem: string | null;
selected: string | undefined;
onPick(name: string): void;
}) {
return (
<nav className="queue" aria-label="Accounts">
<header className="queue-head">
<h1>Account review</h1>
<p>Biggest accounts by net revenue. Open the quiet ones first.</p>
</header>
{!accounts ? (
<p className="queue-note" role="alert">
The account list could not be loaded.
{problem ? <small>{problem}</small> : null}
</p>
) : failed ? (
<p className="queue-note" role="alert">
The list could not be updated for these filters. Change them to try again.
</p>
) : accounts.length === 0 ? (
<p className="queue-note">No accounts match these filters.</p>
) : (
<ol aria-busy={busy}>
{accounts.map((account) => (
<li key={account.name}>
<button type="button" aria-current={account.name === selected} onClick={() => onPick(account.name)}>
<span className="queue-name">{account.name}</span>
<span className="queue-value">{money(account.revenue)}</span>
{isQuiet(account) ? <span className="queue-flag">Quiet for {account.quietDays} days</span> : null}
</button>
</li>
))}
</ol>
)}
</nav>
);
}
function Review({
context,
name,
account,
summary,
busy,
selectionBar,
}: {
context: AppContext;
name: string | undefined;
account: Account | undefined;
summary: string;
busy: boolean;
selectionBar: ReactNode;
}) {
const trend = useObject(context, () => monthlyRevenue(context));
const mix = useObject(context, () => revenueByCategory(context));
const owners = useObject(context, () => revenueByOwner(context));
const orders = useObject(context, () => largestOrders(context));
return (
<section className="review">
<header className="review-head">
<h2>{name ?? 'All accounts'}</h2>
<p>{summary}</p>
{account ? (
<dl aria-busy={busy}>
<div>
<dt>Net revenue</dt>
<dd>{money(account.revenue)}</dd>
</div>
<div>
<dt>Rank</dt>
<dd>{account.rank}</dd>
</div>
<div>
<dt>Orders</dt>
<dd>{account.orders}</dd>
</div>
<div>
<dt>Average order</dt>
<dd>{money(account.averageOrder)}</dd>
</div>
<div>
<dt>Last order</dt>
<dd>{account.lastOrder ? longDate(account.lastOrder) : 'Not recorded'}</dd>
</div>
</dl>
) : null}
{selectionBar}
</header>
<Panel className="hero" title="Is it slowing down?" subtitle="Net revenue by month" {...trend} />
<Panel className="side" title="What do they buy?" subtitle="Net revenue by category" {...mix} />
<Panel className="low" title="Who looks after them?" subtitle="Net revenue by sales rep" {...owners} />
<Panel className="low" title="Which orders stand out?" subtitle="Largest orders by net revenue" {...orders} />
</section>
);
}
/** The queue's own preparation: a missing field fails the queue, and the charts still load. */
function prepareQueue(context: AppContext) {
try {
return { fields: resolveQueueFields(context), problem: null };
} catch (error) {
console.error(error);
return { fields: null, problem: fieldProblem(error) };
}
}
export function App({ astrato }: { astrato: AppContext }) {
const [{ fields, problem }] = useState(() => prepareQueue(astrato));
const selections = astrato.app.client.services.selections;
const selectionState = useSelections(selections);
const selected = selectionState.applied.find(({ field }) => field.fieldKey === fields?.accountSelectionKey)
?.values[0] as string | undefined;
const pick = (name: string) =>
fields &&
selections.selectValues(GROUP, {
field: { fieldKey: fields.accountSelectionKey },
operator: 'inList',
values: [name],
});
const { accounts, busy, failed } = useAccounts(astrato, fields, (rows) => {
// Select before the charts mount, so they open on one account instead of querying twice.
const first = rows.find(isQuiet) ?? rows[0];
if (first) pick(first.name);
});
if (!accounts && !failed) return <main className="boot" aria-busy="true" />;
const account = failed ? undefined : accounts?.find(({ name }) => name === selected);
const summary = account
? takeaway(account)
: !accounts
? 'The account list could not be loaded, so these charts cover all accounts.'
: !selected
? 'Pick an account from the list to review it.'
: failed
? 'Its figures could not be updated for these filters.'
: 'It is not among the biggest accounts under these filters.';
return (
<main className="workbench">
<Queue accounts={accounts} busy={busy} failed={failed} problem={problem} selected={selected} onPick={pick} />
<Review
context={astrato}
name={selected}
account={account}
summary={summary}
busy={busy}
selectionBar={<SelectionBar context={astrato} fields={fields} selections={selections} state={selectionState} />}
/>
</main>
);
}
:root {
--rail: #17332d;
--rail-ink: #eef2ec;
--rail-muted: #a9bdb4;
--page: #f6f1e7;
--surface: #fffcf5;
--line: #e2d9c6;
--ink: #1f2a26;
--muted: #5f6b64;
--accent: #b4532a;
--flag: #f0a868;
--series-1: #1f6f5c;
--series-2: #b4532a;
--radius: 6px;
--gap: 20px;
--font-body: Roboto, system-ui, sans-serif;
--font-display: Fraunces, Georgia, serif;
}
body {
margin: 0;
background: var(--page);
color: var(--ink);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.45;
}
h1,
h2,
p,
ol,
dl,
dd,
figure {
margin: 0;
padding: 0;
}
/* The queue is the page's subject, so it takes a full-height rail rather than a card. */
.workbench {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
height: 100svh;
}
.queue {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
background: var(--rail);
color: var(--rail-ink);
}
/* The ambient layer: slow, low contrast, behind the list and still under reduced motion. */
.queue::before {
content: '';
position: absolute;
inset: -30%;
background: radial-gradient(40% 30% at 30% 20%, rgb(240 168 104 / 14%), transparent 70%);
animation: drift 24s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
to {
transform: translate3d(8%, 18%, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.queue::before {
animation: none;
}
}
.queue-head,
.queue-note,
.queue ol {
position: relative;
}
.queue-note {
overflow-y: auto;
padding: 0 24px 24px;
color: var(--rail-muted);
font-size: 13px;
}
.queue-note small,
.panel-state small {
display: block;
margin-top: 6px;
font-size: 12px;
overflow-wrap: anywhere;
}
/* Figures stay readable while their filtered replacements load. */
[aria-busy='true']:is(.queue ol, .review-head dl) {
opacity: 0.6;
}
.queue-head {
padding: 28px 24px 16px;
}
.queue-head h1 {
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
line-height: 1.15;
}
.queue-head p {
margin-top: 6px;
color: var(--rail-muted);
font-size: 13px;
}
.queue ol {
overflow-y: auto;
list-style: none;
}
.queue button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 12px;
width: 100%;
padding: 11px 24px;
border: 0;
border-left: 3px solid transparent;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.queue button:hover {
background: rgb(255 255 255 / 6%);
}
.queue button[aria-current='true'] {
border-left-color: var(--flag);
background: rgb(255 255 255 / 10%);
}
.queue button:focus-visible {
outline: 2px solid var(--flag);
outline-offset: -2px;
}
.queue-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.queue-value {
font-variant-numeric: tabular-nums;
}
.queue-flag {
grid-column: 1 / -1;
color: var(--flag);
font-size: 12px;
}
/* The answer leads: type, not a tile, carries the selected account's headline. */
.review {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-rows: auto minmax(0, 3fr) minmax(0, 2fr);
gap: var(--gap);
min-height: 0;
padding: 24px 32px 28px;
}
.hero {
grid-column: span 4;
}
.side {
grid-column: span 2;
}
.low {
grid-column: span 3;
}
.review-head {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 4px 40px;
}
.review-head h2 {
grid-column: 1;
font-family: var(--font-display);
font-size: 44px;
font-weight: 600;
line-height: 1.1;
}
.review-head > p {
grid-column: 1;
color: var(--muted);
font-size: 17px;
}
.review-head dl {
grid-area: 1 / 2 / 3 / 3;
display: flex;
gap: 36px;
}
.review-head dt {
color: var(--muted);
font-size: 13px;
}
.review-head dd {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.selections {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 30px;
font-size: 13px;
}
.selections button {
border: 0;
background: none;
color: var(--accent);
font: inherit;
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 10px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
}
.panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
padding: 16px 18px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.panel figcaption {
margin-bottom: 8px;
}
.panel h2 {
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
}
.panel figcaption p {
color: var(--muted);
font-size: 13px;
}
.panel-host {
grid-area: 2 / 1;
min-width: 0;
min-height: 0;
isolation: isolate;
}
.panel-state {
grid-area: 2 / 1;
z-index: 1;
display: none;
place-content: safe center;
overflow-y: auto;
background: var(--surface);
color: var(--muted);
}
.panel[data-state='error'] .panel-state {
display: grid;
}
.panel[data-state='loading'] .panel-host {
opacity: 0;
}
.panel-host {
transition: opacity 240ms ease;
}
@media (prefers-reduced-motion: reduce) {
.panel-host {
transition: none;
}
}
.boot {
min-height: 100svh;
background: var(--rail);
}

This example stays small enough to read as a reference. For a real app, apply the capability checklist, use a stored Business Question for each view and add a change measure where the layer has one. Verify the result with Verifying an app.