factory/dashboard (0.1.3)

Published 2026-08-16 13:47:37 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/dashboard:0.1.3

About this package

The operator contract: one endpoint that answers what this actor is allowed to see, as tiles of integers and closed enums. No free-form payload, no floats, no role on the wire.

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, behind factory.auth + factory.scope. Takes ?window=24h|7d|30d|90d and 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) and Sources\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:

  1. Collect declarations from every registered source.
  2. Drop the ones belonging to the other surface.
  3. Drop the ones whose requires this actor does not hold.
  4. 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 || ^0.4 || ^0.5 || ^0.6
factory/infra ^0.1 || ^0.2 || ^0.3
factory/kernel ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10
factory/providers ^0.1 || ^0.2
factory/treasury ^0.1 || ^0.2 || ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7
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
Details
Composer
2026-08-16 13:47:37 +00:00
32
proprietary
47 KiB
Assets (1)
Versions (4) View all
0.1.3 2026-08-16
0.1.2 2026-08-14
0.1.1 2026-08-07
0.1.0 2026-08-06