factory/dashboard (0.1.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/dashboard:0.1.0About this package
factory/dashboard — v0.1.0
The operator contract. One endpoint that answers a single question — what is this actor allowed to see, and what does it say right now? — as tiles of integers and closed enums.
Two operator surfaces exist across the fleet (DASHBOARD_DOCTRINE §2): the platform cockpit, where the founders watch twelve products, and the project dashboard, where a merchant watches theirs. They are not the same screen with a filter on it. They are different screens, and this package is the one place that decides which one a caller is on.
The invariant the whole package exists to hold:
A tile has no field a secret could occupy.
{@see TileValue} is one int, a closed kind, and — where it is money — a
currency code checked against factory/kernel's table. There is no data, no
meta, no extra, no arbitrary map, and deliberately no text() constructor.
A source author who wanted to put a provider credential on an operator screen
would have to add a property to that file to do it, which is a code review rather
than an accident.
Provides
- The endpoint —
GET /api/dashboard, behindfactory.auth+factory.scope. Takes?window=24h|7d|30d|90dand nothing else. - Registry —
DashboardRegistry, a singleton.register(TileSource)at boot is the extension point: a product's own service provider adds its domain tiles and this package never needs to know what the product sells. - The tile vocabulary —
Tile(stat/bars/ring/nav),Reading,TileValue,TileDelta, and four closed enums the client switches on:TileKind,TileGroup(money · operations · health · growth),TileIcon(16 names, no glyphs),Polarity. - The tiles this package ships itself —
Sources\TreasuryTiles(float ring, collected, withdrawable, our cut, fleet movements, projects trading) andSources\CapabilityTiles(capability coverage, critical gaps, the cockpit doorway).
There is no ?surface=
The surface is derived from the actor, never requested:
| token | X-Product-Slug |
surface |
|---|---|---|
| platform membership | absent | platform — the fleet |
| platform membership | demo |
project — a drill-down into demo |
| project membership | absent | project — their own, always |
| project membership | another project | 403, from factory.scope |
| staff / customer | any | 403 — neither holds project.read |
A ?surface=platform parameter would be a request the server has to refuse, and
a request the server has to refuse is a request somebody will eventually forget
to refuse. There is no route to guard because there is no parameter to lie about.
There is likewise no role gate on the route. Abilities already answer the
question, and answer it more finely: Surface::requires() is platform.read for
the fleet and project.read for a project, and individual tiles narrow further —
treasury.platform_revenue sits on the project surface and still requires
platform.read, so the founders and the merchant open the same screen and one
card is simply not on theirs.
Order of operations (the part that leaks)
DashboardRegistry::visible() does exactly this, in this order:
- Collect declarations from every registered source.
- Drop the ones belonging to the other surface.
- Drop the ones whose
requiresthis actor does not hold. - Only then run what survives.
A tile the actor may not see never runs its query. Resolving first and filtering after produces the same JSON and a different slow-query log, and the rows have already been read on behalf of somebody who may not read them.
The cache key is derived, never supplied
factory.dashboard:v1:{surface}|{product}|{window}|{tile} — assembled by the
registry from the context, never by the tile. A source author cannot pick a key,
so a source author cannot pick a key that collides. Cross-tenant leakage through
a shared cache entry is not a discipline here; it is unreachable.
Cached readings are wrapped ['r' => ?array], because an unwrapped null caches
as a miss — and the expensive query it stands for would then run on every
request forever, warm cache or not.
cache_ttl of 0 (or less) bypasses the cache entirely. The test harness sets
it to 0 and turns it on only in the two tests that are about caching.
Integers, all the way down
No float crosses this boundary — enforced by the int type on every constructor,
and pinned by a doctrine test that walks the encoded JSON rather than the
object graph, because a rule enforced in a constructor and violated by a
serializer is a rule nobody is keeping.
- Money is minor units plus an ISO code. Never
250.0. - Proportions are basis points. 10000 is one hundred percent. A dashboard that rounds 0.125 to "13%" in one tile and "12%" in another has taught its operator to distrust both.
- Series are
list<int>, never longer than the window has buckets.
Deltas are separate from goodness. TileDelta carries direction and bps;
the tile carries polarity. Refunds up and volume up are the same arrow and
opposite news, and a screen where every arrow-up is green teaches its operator,
within a fortnight, that green means nothing. The server sends no colour.
A change from nothing is not a percentage. Previous 0 → comparable: false,
bps: 0, and the client renders "new". A dashboard that renders "+400000%" here
has never traded on a Monday morning.
A ring arrives already divided. fill_bps is computed server-side, in
integers, because a limit of zero is the normal state of a project on its first
day and a client dividing by it paints a NaN sweep angle into an arc and takes
the screen with it.
What the platform surface refuses to do
It counts; it does not sum. 30,000 XAF plus 90,000 KES is not 120,000 of
anything. So the fleet screen reports movements posted and projects trading —
both true in every currency at once — and every money figure lives on a project
surface, where there is exactly one currency: the product's own. data.currency
is null on the platform surface, deliberately, because naming one would be the
first step toward adding two of them up.
Buckets are cut in PHP, not SQL
DATE_FORMAT is MySQL, strftime is SQLite, date_trunc is Postgres, and a
dashboard is precisely the wrong place to discover which one this deployment
runs. Rows come back as (timestamp, amount) and are bucketed in a loop, with the
60-second cache in front making the cost a non-question. A row stamped a second
into the future by clock skew lands in the last bucket rather than nowhere.
The payload
{
"ok": true,
"data": {
"surface": "project",
"product": "demo",
"currency": "XAF",
"window": { "key": "30d", "label": "30 days", "days": 30, "points": 30, "bucket_seconds": 86400 },
"windows": ["24h", "7d", "30d", "90d"],
"generated_at": "2026-07-25T12:00:00+00:00",
"stale_after": "2026-07-25T12:01:00+00:00",
"groups": [
{
"key": "money",
"label": "Money",
"tiles": [
{
"key": "treasury.collected",
"kind": "bars",
"group": "money",
"title": "Collected",
"subtitle": "Money that reached the gateway this window",
"icon": "wallet",
"polarity": "higher_better",
"columns": 2,
"route": null,
"reading": {
"kind": "amount",
"value": 25000,
"currency": "XAF",
"delta": { "direction": "up", "bps": 30000, "comparable": true },
"series": [0, 0, 25000],
"limit": null,
"fill_bps": null
}
}
]
}
]
},
"meta": {}
}
An unknown window falls back to the default rather than 422ing: the query string is the one input an operator can mangle by editing the URL bar, and a technically correct refusal there leaves them staring at an error page during an incident.
A tile whose reading comes back null is omitted, not zeroed — "0 orders"
and "we could not count your orders" are different sentences. Nav tiles have
nothing to measure and stay regardless. Empty groups do not ship at all, and
groups arrive in doctrine order: money, operations, health, growth.
Adding tiles from a product
$this->app->make(DashboardRegistry::class)->register(new class implements TileSource {
public function tiles(DashboardContext $context): iterable
{
yield Tile::stat(
key: 'bookings.today',
surface: Surface::Project,
group: TileGroup::Operations,
title: 'Bookings today',
read: fn (): ?Reading => Reading::of(TileValue::count($this->countToday($context))),
icon: TileIcon::Calendar,
requires: Ability::ProjectRead,
);
}
});
tiles() declares; the read closure runs only if the declaration survives
steps 2 and 3 above. Two sources claiming one key is a DashboardException at
snapshot time rather than a silently dropped card.
Config expected from the host app
config/dashboard.php — cache_ttl (60) and default_window (30d). Reads no
env keys. Publish with
php artisan vendor:publish --tag=factory-dashboard-config.
Not in this package
The middleware. The gate is factory/auth's, the tenant binding is factory/auth's, and a dashboard that grew its own authentication would be a second answer to a question the platform has already answered once.
The rendering, too. factory_ui's DashboardTile and its CustomPainter charts
consume this contract; nothing here knows a colour, a radius or a font. The only
strings on the wire are the title and subtitle a source author typed literally.
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/auth | ^0.1 || ^0.2 || ^0.3 |
| factory/infra | ^0.1 || ^0.2 |
| factory/kernel | ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 |
| factory/providers | ^0.1 || ^0.2 |
| factory/treasury | ^0.1 || ^0.2 || ^0.3 || ^0.4 |
| illuminate/cache | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/http | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/routing | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |