ReviewOS

also looking at this

stacks/reportshq

Expand BunPress guide and pre-E2E quality gates

#35
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
40 files +1524 -373

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/api.mdmodified+3-1
Changes to docs/api.md
@@ -81,7 +81,9 @@ Layout is packed on the server as well as in the browser, and the response
8181carries the canonical positions back. What is stored and what you saw cannot
8282disagree.
8383
84## Sharing
84## Laravel sharing
85
86The current Laravel source exposes the following share endpoints when its API is enabled and guarded. The Stacks `reportRoutes` list does not contain share routes, and this site's code-defined viewer does not mount them.
8587
8688```bash
8789curl -X POST .../api/reportshq/reports/commerce-overview/shares -d '{"label":"Board"}'
docs/builder.mdmodified+41-135
Changes to docs/builder.md
@@ -1,144 +1,50 @@
11# Report builder
22
3A report is a grid of blocks. Each block runs one query and draws one thing.
4
5The terms here are the terms the interface uses, because both come from the
6same validation schema in `app/Reports/schema.ts`. A test asserts this page
7lists exactly the values that schema accepts, so a new measure cannot appear in
8the product without appearing here.
9
10## Blocks
11
12| Kind | Draws |
13|---|---|
14| `line` | A measure over time |
15| `area` | The same, filled, for one dominant series |
16| `bar` | A measure across a dimension, or over coarse time |
17| `donut` | Composition of a total across a dimension |
18| `big_number` | One number, and whether it moved |
19| `table` | The underlying rows, when the shape is not a chart |
20| `funnel` | Conversion across ordered steps |
21| `heatmap` | A measure across two dimensions |
22| `text` | Prose. The only kind that runs no query |
23
24## The query
25
26Every block except `text` carries a query with these parts.
27
28### events
29
30The event names the block reads. Empty means every event in the project. Names
31come from the models you described in `config/reportshq.php`.
32
33### measure
34
35What to compute:
36
37| Measure | Means |
38|---|---|
39| `count` | Rows. The default, and what a count of events means |
40| `count_unique` | Distinct `user_key`. Unique visitors, active users, buyers |
41| `sum` | Sum of the named field. Revenue, duration, quantity |
42| `avg` | Mean of the named field. Order value, session length |
43| `min` | Smallest value of the named field |
44| `max` | Largest value of the named field |
45
46`sum`, `avg`, `min` and `max` require a **field**. `count` and `count_unique`
47ignore one.
48
49### field
50
51Which value the measure reads. One of `name`, `user_key`, `session_key`,
52`value`, `currency`, `occurred_at`, or `properties.<key>` for anything in your
53own property bag.
54
55This is an allowlist rather than a pattern. These names reach a query builder,
56and "anything that looks like an identifier" is how a column name becomes an
57injection point the day somebody builds the query with a template string.
58
59### dimension
60
61Group the result into series by a field, using the same set as above. Bounded
62by **limit** with an explicit other bucket: when two hundred tags become the top
63ten, the report says how many went into Other rather than letting a reader
64assume the tail does not exist.
65
66### filters
67
68Narrow what the block counts. Each filter is a field, an operator and usually a
69value:
70
71| Operator | Means |
72|---|---|
73| `is` | Equal to |
74| `is_not` | Not equal to |
75| `contains` | Substring |
76| `starts_with` | Prefix |
77| `gt` | Greater than |
78| `lt` | Less than |
79| `exists` | The field is present |
80| `not_exists` | The field is absent |
81
82`exists` and `not_exists` carry no value, because the test is presence.
83
84### grain
85
86How time is bucketed: `hour`, `day`, `week` or `month`.
87
88### compare
89
90Compare against the previous period of equal length. When there was no previous
91period, the block says so rather than inventing a percentage. This is how
92dashboards end up claiming a four thousand percent rise, and it is always
93because the previous period was zero.
94
95### limit
96
97Top N series when a dimension is set. The rest become Other, and the count is
98stated.
99
100### steps
101
102Ordered event names, for `funnel` blocks only. A funnel measures people, not
103events, so when data is sampled the sampling keeps whole subjects: somebody is
104either wholly in the sample or wholly out. Sampling events independently makes
105every conversion rate noise, because the steps stop belonging to the same
106people.
107
108## The grid
109
110Twelve columns. Drag a block to move it, its corner to resize, and everything
111has a keyboard path. Blocks push each other out of the way while you hold one,
112and the server settles the layout before saving, so two blocks can never share
113a cell. That last part is on the server deliberately: a layout agreed by one
114browser is a layout that disagrees with itself when two people edit at once.
115
116## Draft and published
117
118Edits go to a draft. The published version is what a teammate opens and what a
119[share link](/docs/sharing) serves, and it does not change until you publish. A
120half finished experiment is never what somebody else walks into.
3A report is a grid of blocks. The current source has two storage models: `@reportshq/stacks` asks the host for a `ReportStore`, while `reportshq/laravel` ships Eloquent report tables and a browser editor. The editor in this site's old hosted app was removed. Its read-only Accounts report is defined in `config/reportshq.ts`.
4
5## Block kinds
6
7Both current packages accept the same nine kind names in `packages/stacks/src/http/handlers.ts` and `packages/laravel/src/Reports/Block.php`:
8
9| Kind | Use |
10| --- | --- |
11| `big_number` | A headline value |
12| `line` | A time series |
13| `area` | A filled time series |
14| `bar` | Values over time or categories |
15| `donut` | Category composition |
16| `table` | Values in rows |
17| `funnel` | A funnel chart frame |
18| `heatmap` | A heatmap chart frame |
19| `note` | Text without a query |
20
21The current query engines do not have a separate ordered-step funnel calculation or two-dimensional heatmap aggregation. Do not infer those analytical semantics from the renderer kind alone.
22
23## Query vocabulary
24
25Each query-bearing block starts with `model` and `measure`, both looked up in the semantic registry. Measures are described with `count`, `sum`, `avg`, `min`, or `max`. Only `count` needs no column. The TypeScript block shape is `BlockQuery` in `packages/stacks/src/types.ts`:
26
27```ts
28{
29 model: 'order',
30 measure: 'revenue',
31 time: { key: 'placed' },
32 grain: 'day',
33 from: '2026-08-01',
34 to: '2026-09-01',
35}
36```
12137
122## Every block explains itself
38`dimension` groups by an allowlisted column. `time` selects an allowlisted date column on the measured model; `grain` can be `hour`, `day`, `week`, or `month`. `from` is inclusive and `to` is exclusive. A date range without `time` uses the model's first described time column. `limit` bounds a dimensioned result to at most 500 rows in the TypeScript compiler. The current TypeScript compiler accepts `=`, `!=`, `>`, `>=`, `<`, `<=`, and `like` for filters. Laravel's `Filter` uses the named operators `is`, `is_not`, `contains`, `starts_with`, `gt`, `lt`, `exists`, and `not_exists`. Do not copy a filter payload between the two runtimes without translating the operator vocabulary.
12339
124Each block carries one sentence derived from the query it actually runs, not
125from a label somebody typed. Two charts both called Revenue can count different
126things, and a reader deserves to know which one is in front of them before they
127quote a number in a meeting.
40`compare` is present in the TypeScript type, but the current TypeScript compiler returns `comparison: null`. It does not yet compute a prior-period change. The retired event fields `events`, `user_key`, `session_key`, `properties.<key>`, and `steps` are not this reporting query schema.
12841
129## Report filters
42The registry supplies every SQL identifier and the caller supplies only keys and parameterized values. A column not described in the registry is unreachable by a block request.
13043
131A whole report can be narrowed from its URL:
44## Editing and publishing
13245
133```
134/reports/my-report?f=properties.plan:is:pro
135```
46A Stacks host can keep reports in code and make the viewer read-only. The browser builder is writable only when the store implements `addBlock`, `saveBlock`, `removeBlock`, and `publish`; a partially implemented editor is not advertised as writable. The host mounts the package's route descriptions and chooses auth and persistence. This site intentionally does not mount the builder.
13647
137The form is `f=field:operator:value`, repeated, up to five. It lives in the
138query string and nowhere else, so a narrowed view is linkable: paste the URL
139into a conversation and the reader sees what you saw.
48In Laravel, `Builder` adds blocks, validates their kind, packs a 12-column layout on the server, and publishes a revision. The package's standalone editor is under `/reports/{slug}/edit` only after `routes.enabled` is set and the host chooses suitable middleware. The Filament plugin is another optional surface.
14049
141Operators here are limited to `is`, `is_not` and `contains`. A malformed filter
142is dropped rather than erroring, so a link with one bad filter still shows the
143report. Funnels are left alone, because quietly narrowing their steps would
144change what the conversion rate measures without saying so.
50One impossible block returns its reason in `error` while the report can still render other blocks. A fan-out relation that would multiply a sum is refused instead of shown as a plausible wrong number. See [the query API](/docs/api) for the result shape.
docs/concepts.mdmodified+2-2
Changes to docs/concepts.md
@@ -19,7 +19,7 @@ measures: {
1919
2020## Dimension
2121
22A dimension groups a measure, such as status, country or plan. Dimensions are bounded where necessary so a chart cannot accidentally request millions of categories. Values outside the displayed set are reported honestly as `Other` when the block supports it.
22A dimension groups a measure, such as status, country or plan. The current TypeScript compiler can limit a dimensioned result to at most 500 rows. It does not synthesize an `Other` bucket for omitted values, so a limited chart is not a complete total.
2323
2424## Time grain
2525
@@ -37,7 +37,7 @@ A block is one report tile. Its kind controls presentation, while its query sele
3737
3838## Draft and published report
3939
40The builder edits the draft. Publishing creates the version shown by ordinary report views and share links. A half-finished layout therefore cannot leak through a public share merely because an editor moved a tile.
40In the current Laravel source, the builder edits a draft and publishing creates the revision shown by ordinary views and share links. In a Stacks host, draft storage and publishing depend on its `ReportStore`; this site's code-defined report has no browser editor or share link.
4141
4242## Refusal
4343
docs/configuration.mdmodified+9-9
Changes to docs/configuration.md
@@ -33,22 +33,22 @@ The Stacks package does not ship database tables. Provide a `ReportStore` backed
3333return [
3434 'models' => [/* declared model descriptions */],
3535 'license' => env('REPORTSHQ_LICENSE'),
36 'timezone' => env('REPORTSHQ_TIMEZONE', 'UTC'),
36 'connection' => env('REPORTSHQ_CONNECTION'),
3737 'routes' => [
38 'enabled' => env('REPORTSHQ_ROUTES', true),
39 'prefix' => env('REPORTSHQ_PREFIX', 'reports'),
38 'enabled' => env('REPORTSHQ_ROUTES', false),
39 'prefix' => env('REPORTSHQ_ROUTE_PREFIX', 'reports'),
4040 'middleware' => ['web', 'auth'],
4141 'share_middleware' => ['web'],
4242 ],
4343 'api' => [
4444 'enabled' => env('REPORTSHQ_API', false),
4545 'prefix' => env('REPORTSHQ_API_PREFIX', 'api/reportshq'),
46 'middleware' => ['api', 'auth:sanctum'],
46 'middleware' => ['api'],
4747 ],
4848];
4949```
5050
51Publish the package configuration and migrations using the commands documented in [the Laravel guide](/docs/laravel). Review generated routes before enabling the JSON API.
51These are values in the current repository source, not the published v0.1.0 package. Once a reporting release exists, publish its configuration with the command in [the Laravel guide](/docs/laravel). The package loads its own migrations. Set page and API middleware for your host before enabling either surface; the source defaults shown here do not add an auth guard.
5252
5353## Model descriptions
5454
@@ -62,9 +62,9 @@ Keep the registry narrow:
6262
6363## Middleware
6464
65Page, API and share routes have separate middleware because they serve different callers. Page routes commonly use sessions, API routes use tokens, and a share link may be intentionally public to anyone holding its revocable token.
65The current Laravel source has separate page, API and share middleware. The page and API defaults are `web` and `api`, respectively, without an application-specific auth guard. Add your own auth policy before enabling them. Its share link may be intentionally public to anyone holding a revocable token. The Stacks package describes no share route today.
6666
67An empty share middleware list does not make every report public. Only an active share token exposes its published report. Add rate limiting, trusted proxy and application-specific policy middleware when required.
67An empty share middleware list does not make every report public. Only an active share token exposes its published report in Laravel. Add rate limiting, trusted proxy and application-specific policy middleware when required.
6868
6969## Environment values
7070
@@ -73,9 +73,9 @@ Common Laravel environment settings include:
7373| Variable | Purpose |
7474| --- | --- |
7575| `REPORTSHQ_LICENSE` | Optional offline license key |
76| `REPORTSHQ_TIMEZONE` | Default report timezone |
76| `REPORTSHQ_CONNECTION` | Reporting database connection |
7777| `REPORTSHQ_ROUTES` | Enable packaged page routes |
78| `REPORTSHQ_PREFIX` | Page route prefix |
78| `REPORTSHQ_ROUTE_PREFIX` | Page route prefix |
7979| `REPORTSHQ_API` | Enable the JSON API |
8080| `REPORTSHQ_API_PREFIX` | JSON API route prefix |
8181
docs/index.mdmodified+13-13
Changes to docs/index.md
@@ -1,11 +1,11 @@
11---
22layout: home
33title: ReportsHQ Documentation
4description: Reports that run inside your own Laravel application, against your own database. Installing the package, describing your models, the query API and the report builder.
4description: In-process reporting source for Stacks and Laravel. Release status, model allowlists, query APIs, and builder behavior.
55hero:
66 name: ReportsHQ
7 text: Reports that build themselves
8 tagline: "Describe the models you already have. The Commerce, Users and Content reports appear with real numbers in them, and nothing leaves your servers."
7 text: Reports inside your application
8 tagline: "Describe the models and columns reporting may read, then define a report against your own database. Check release status before installing."
99 actions:
1010 - theme: brand
1111 text: Quickstart
@@ -17,28 +17,28 @@ hero:
1717 text: View on GitHub
1818 link: https://github.com/stacksjs/reportshq
1919features:
20 - title: One engine
21 details: Install it in Stacks or Laravel, name the models it may read, and keep every query on the application's existing database connection.
20 - title: Two in-process packages
21 details: The current Stacks and Laravel source query the host application's database. Their reporting rewrites are not yet in the published package versions.
2222 - title: Describe once, query safely
2323 details: Name your models, measures and dimensions in one config file. That description is an allowlist, so a block can only ever reach what you named.
2424 - title: It refuses rather than guesses
2525 details: Ask for a measure at a grain it does not have and the block says why, instead of returning a number that is quietly wrong.
26 - title: Honest about its numbers
27 details: No previous period is said rather than guessed at, bounded dimensions state what went into Other, and sampling keeps whole people.
28 - title: Share without an account
29 details: A link shows one published report and nothing else. Revoke it and it stops working on the next request.
26 - title: Honest about refusals
27 details: A block reports why it could not run instead of presenting a plausible wrong number. The current TypeScript compiler does not calculate prior-period comparison.
28 - title: Laravel sharing
29 details: The current Laravel source has revocable links for one published report. The Stacks package does not yet describe a share route.
3030 - title: It never sees your data
31 details: Queries run in process through your own ORM. No connection to hand out, no endpoint to send to, and an offline licence check that opens no sockets.
31 details: Reporting queries run in process on the application's own connection, not through ORM scopes. The Laravel event forwarder is separate and optional.
3232---
3333
3434## Start here
3535
3636| You want to | Read |
3737|---|---|
38| Get from nothing to a report | [Quickstart](/quickstart) |
38| Check release status and preview the current source | [Quickstart](/quickstart) |
3939| Understand measures, dimensions and refusals | [Reporting concepts](/concepts) |
40| Install it into a Stacks application | [Stacks package](/stacks) |
41| Install it into a Laravel application | [Laravel package](/laravel) |
40| Integrate the Stacks source after release | [Stacks package](/stacks) |
41| Integrate the Laravel source after release | [Laravel package](/laravel) |
4242| Review every integration setting | [Configuration](/configuration) |
4343| Read the JSON the charts consume | [Query API](/api) |
4444| Build a report of your own | [Report builder](/builder) |
docs/laravel.mdmodified+16-8
Changes to docs/laravel.md
@@ -4,20 +4,28 @@
44models you already have, queries them in place on the connection your
55application already holds, and renders through the routes you already guard.
66
7Nothing leaves the application. There is no endpoint to send to, no connection
8to hand out, and the licence check is offline.
7The report engine does not send rows to a vendor endpoint and the licence check
8is offline. A separate optional event forwarder can send taxonomy events to a
9collector explicitly configured by the host; it is not required for reports.
910
1011## Install
1112
13Do not run that install sequence yet for the reporting product. Packagist
14currently serves v0.1.0, whose published metadata describes the earlier event
15forwarder. This repository's current source adds in-process reporting, but that
16source has not been released on Packagist. Verify a release containing it before
17following the install sequence. The current source loads migrations for
18reports, blocks, revisions, shares and schedules. Your own tables are only ever
19read by the report engine.
20
21After a reporting release exists, the host installation sequence is:
22
1223```bash
1324composer require reportshq/laravel
1425php artisan vendor:publish --tag=reportshq-config
1526php artisan migrate
1627```
1728
18The migration creates the tables the reports live in: reports, blocks,
19revisions, shares and schedules. Your own tables are only ever read.
20
2129## Describing a model
2230
2331```php
@@ -103,9 +111,9 @@ same compiled components in all three, so they cannot drift.
103111
104112## Exports, sharing, schedules
105113
106CSV and XLSX are generated on demand rather than stored and linked. The numbers
107are one query away, so there is nothing to clean up and no way to serve a stale
108copy.
114The current Laravel source generates CSV and XLSX on demand. Its CSV is one
115long table with block, point, series and value columns. Its XLSX uses one sheet
116per query-bearing block. The current Stacks package generates CSV only.
109117
110118Sharing and scheduling are documented in [sharing](/docs/sharing) and
111119[schedules and exports](/docs/schedules-exports).
docs/limits.mdmodified+6-3
Changes to docs/limits.md
@@ -22,9 +22,12 @@ a good quarter.
2222| XLSX export | | Yes | Yes |
2323| Priority support | | | Yes |
2424
25`app/Billing/limits.ts` is the only place these numbers live. The pricing page
26renders from it rather than restating it, because a marketing table maintained
27separately is a promise somebody eventually breaks by editing one of them.
25`app/Billing/limits.ts` supplies pricing copy. It is not a runtime feature gate
26or a proof that every listed capability exists. The current Stacks package has
27CSV but not XLSX, sharing or scheduled delivery. The Laravel source implements
28CSV, XLSX, sharing and schedules, but its reporting rewrite has not yet been
29published on Packagist. Check [release status](/docs/quickstart) before treating
30this table as an installation plan.
2831
2932## Reports are unlimited on every tier
3033
docs/quickstart.mdmodified+30-106
Changes to docs/quickstart.md
@@ -1,36 +1,28 @@
11# Quickstart
22
3Empty application to first report, in about five minutes. Nothing here talks to
4a service, because there is not one: everything below runs on your machine
5against your own database.
3ReportsHQ runs reporting queries inside the application that owns the data. The current reporting implementations live in this repository under `packages/stacks/` and `packages/laravel/`. Neither needs a hosted collector for reports.
64
7## 1. Install the package
5## Check the release before installing
86
9Two ecosystems, one engine. Pick yours; everything after this step is the same
10report either way.
7As of September 2026, npm serves `@reportshq/stacks` 0.1.0 and Packagist serves `reportshq/laravel` v0.1.0. Those published artifacts predate the reporting rewrite. Installing either version does **not** give you the reporting API described below. The TypeScript reporting source is versioned 0.2.0 in this checkout, but it has not been published. The Laravel reporting source also differs from its published artifact.
8
9Do not use `bun add @reportshq/stacks` or `composer require reportshq/laravel` as a reporting setup step until releases containing the current source are available. You can inspect and test the current implementations in a working checkout of this repository:
1110
1211```bash
13# Stacks. No migration: the package ships no tables and asks your
14# application where reports are kept. See /docs/stacks.
15bun add @reportshq/stacks
12cd packages/stacks
13bun test ./tests
1614```
1715
1816```bash
19# Laravel
20composer require reportshq/laravel
21php artisan vendor:publish --tag=reportshq-config
22php artisan migrate
17cd ../..
18php packages/laravel/tests/run.php
2319```
2420
25In Laravel the migration creates the tables the reports themselves live in: the
26report, its blocks, its revisions, its share links and its schedules. In Stacks
27there is no migration, because the package keeps no tables of its own and asks
28the application where reports live instead. Either way your own tables are never
29touched, read-only or otherwise altered.
21The following steps describe the current source and what a host application must supply when the reporting packages are released. They are not a claim that a fresh customer install works today.
3022
31## 2. Describe a model
23## 1. Describe queryable models
3224
33Name something you already have.
25Name only the tables and columns a report may read. This is the security boundary for query identifiers.
3426
3527```ts
3628// Stacks: config/reportshq.ts
@@ -43,18 +35,14 @@ export const models: Record<string, ModelDescription> = {
4335 revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' },
4436 orders: { aggregate: 'count' },
4537 },
46 time: {
47 placed: 'created_at',
48 },
49 dimensions: {
50 status: 'status',
51 },
38 time: { placed: 'created_at' },
39 dimensions: { status: 'status' },
5240 },
5341}
5442```
5543
5644```php
57// Laravel: config/reportshq.php
45// Laravel: config/reportshq.php, inside the returned array
5846'models' => [
5947 'order' => [
6048 'class' => App\Models\Order::class,
@@ -72,96 +60,32 @@ export const models: Record<string, ModelDescription> = {
7260],
7361```
7462
75That is the whole description. A measure says what to add up and how, a
76`time` column is what a date range applies to, and every dimension is something
77you may group by. Where a relation would multiply rows, saying so is what lets
78the compiler refuse a question that would double-count rather than answer it
79approximately.
80
81**Only what you name is reachable.** The compiler will not touch a column that
82is not in this file, which is why a password hash cannot end up as a dimension
83by somebody typing its name into a URL.
84
85## 3. Open the reports
86
87The routes are off until you say otherwise, because the package cannot know
88which of your users may see a total of everybody's orders. Turn them on:
89
90```bash
91REPORTSHQ_ROUTES=true
92```
93
94```bash
95php artisan serve
96```
97
98Visit `/reports`. It is empty -- nothing creates a report for you, and the page
99says as much. Make the first one:
63Compiled SQL does not apply ORM scopes or soft deletes automatically. Declare the same row restrictions in the reporting model description when those rows must be excluded.
10064
101```php
102use ReportsHQ\Laravel\Reports\Builder;
103
104// The slug is derived from the name, and kept unique for you.
105$report = app(Builder::class)->create('Commerce');
106```
107
108Or post the same thing from the page itself, which is what the "New report"
109form does.
65## 2. Decide where reports are stored
11066
111## 4. Build it
67The Stacks package brings no report tables. Supply a `ReportStore` with `list`, `find`, `blocks`, and `saveLayout`, backed by code or your own persistence. Write methods are optional. This site defines its one Accounts report in `config/reportshq.ts` and keeps the viewer read-only. See [the Stacks integration](/docs/stacks) for the store and route descriptions.
11268
113Visit `/reports/commerce/edit`. Drag a block from the palette onto the grid,
114pick a measure and a dimension, and it redraws as you change it. Drag a corner
115to resize, or nudge with the arrow keys.
69The Laravel package source ships migrations for reports, blocks, revisions, shares, and schedules. Its `Builder` creates and publishes reports. The standalone pages are off by default; enable them only after choosing middleware that protects the data they expose. See [the Laravel integration](/docs/laravel).
11670
117There is no data to accumulate and nothing to wait for: a block reads rows that
118are already in your database, so the first one you drop covers everything you
119have ever sold.
71## 3. Build a first report
12072
121Publish when it looks right. A draft stays yours until you do, so a teammate
122never opens a half arranged grid.
73A block names a model and one of that model's described measures. A time key or dimension is optional:
12374
124## 5. Share it, or send it
125
126A published report can be given a link that works for somebody with no account:
127
128```php
129use ReportsHQ\Laravel\Reports\Share;
130
131$share = Share::create([
132 'report_id' => $report->id,
133 'token' => Share::newToken(),
134 'label' => 'For the board',
135 'expires_at' => now()->addDays(30),
136]);
75```ts
76{ model: 'order', measure: 'revenue', time: { key: 'placed' }, grain: 'day' }
13777```
13878
139Or emailed on a cadence, from your own queue, in the report's own timezone:
140
141```php
142use ReportsHQ\Laravel\Reports\Schedule;
143
144Schedule::create([
145 'report_id' => $report->id,
146 'cadence' => 'weekly',
147 'hour' => 8,
148 'recipients' => 'ops@example.com',
149 'format' => 'xlsx',
150]);
151```
79In a Stacks host, put this query in a stored block and mount the package's route descriptions behind your application's auth policy. In a Laravel host using the current source, `Builder::create('Commerce')` creates a draft; `Builder::addBlock()` adds a block; `Builder::publish()` records the published revision. The Laravel browser editor exists only when the standalone routes are enabled. This site's viewer does not mount an editor.
15280
153Add the runner to `routes/console.php` and the application does the rest:
81The compiler returns an explicit refusal on a block when a query names an undeclared field or would multiply a measure across a fan-out relation. It does not send a query to ReportsHQ servers.
15482
155```php
156Schedule::command('reportshq:send')->hourly();
157```
83## 4. Export or distribute
15884
159It is registered by the package but scheduled by you, on purpose. A package that
160adds itself to your scheduler is a package that sends email nobody asked for.
85The Stacks package currently downloads CSV only. The Laravel source supports CSV and XLSX, plus local sharing and scheduled delivery. Those Laravel features require the host's route, middleware, queue, and mail decisions. See [exports and schedules](/docs/schedules-exports) and [sharing](/docs/sharing) for the package-specific behavior.
16186
16287## What next
16388
164- [The Stacks package](/docs/stacks) or [the Laravel package](/docs/laravel)
165 for the full description format in your own language.
166- [The query API](/docs/api) for the JSON the charts read.
167- [What a licence covers](/docs/limits), and why none of it gates a report.
89- [Reporting concepts](/docs/concepts) explains measures, dimensions, time grains, and fan-out.
90- [The builder](/docs/builder) gives the current block and query vocabulary.
91- [Configuration](/docs/configuration) shows the separate route and API switches.
docs/schedules-exports.mdmodified+22-17
Changes to docs/schedules-exports.md
@@ -5,24 +5,25 @@ without anybody asking.
55
66## Exports
77
8Export any report as CSV or XLSX.
8The current Stacks source exports CSV only. Its handler rejects XLSX. The
9current Laravel source exports both CSV and XLSX. The published package
10versions still predate this reporting source, so check [release status](/docs/quickstart)
11before using either as a customer installation.
912
10Both come out in **long format**: one row per series per period, rather than a
11grid that looks right on screen and fights every pivot table. A wide export is
12easier to read and harder to use, and an export exists to be used.
13CSV is one long table with block, point, series and value columns. Laravel XLSX
14uses a separate sheet per query-bearing block, with point, series and value
15columns. The two formats are deliberately different.
1316
14| period | series | value |
15|---|---|---|
16| 2026-08-01 | pro | 4250 |
17| 2026-08-01 | starter | 1180 |
18| 2026-08-02 | pro | 3990 |
17| block | point | series | value |
18|---|---|---|---|
19| Signups | 2026-08-01 | pro | 4250 |
20| Signups | 2026-08-01 | starter | 1180 |
21| Signups | 2026-08-02 | pro | 3990 |
1922
2023CSV suits anything that reads text. XLSX gives a workbook with the types
2124already correct, so dates are dates rather than strings that need coercing.
22Both come from the same query the chart ran, so an exported number and a drawn
23number cannot disagree.
24
25XLSX is a paid-tier capability; CSV is on every plan. See [limits](/docs/limits).
25Both are generated from the rendered report result. The offline licence does
26not gate either format in the current source. See [limits](/docs/limits).
2627
2728## Nothing is stored to expire
2829
@@ -36,12 +37,14 @@ demand has nothing to clean up and cannot serve somebody a stale copy.
3637
3738## Schedules
3839
39A schedule sends a report's headline numbers by email, daily, weekly or
40monthly, at an hour you choose, optionally with a spreadsheet attached.
40The current Laravel source can send a report by email on a schedule, optionally
41with a spreadsheet. The Stacks package has no schedule runner. Laravel registers
42the `reportshq:send` command but the host must put it on its own scheduler and
43run a queue worker for queued mail.
4144
4245### The hour stays the hour
4346
44The schedule is evaluated in your project's timezone rather than by adding a
47The schedule is evaluated in the report's timezone rather than by adding a
4548fixed offset to UTC. That means 09:00 stays 09:00 after the clocks change,
4649which is the whole reason to store a timezone rather than an offset.
4750
@@ -63,4 +66,6 @@ schedule is due, so it is written *before* the mail is queued. A send that
6366fails afterwards still leaves the schedule looking like it ran. There is no
6467failure log here: if you need one, watch your queue's failed jobs.
6568
66Scheduled delivery is a paid-tier capability. See [limits](/docs/limits).
69The current offline licence does not gate scheduled delivery. Pricing copy may
70describe a commercial tier, but the host remains responsible for mail access,
71recipients and delivery monitoring. See [limits](/docs/limits).
docs/self-hosting.mdmodified+26-18
Changes to docs/self-hosting.md
@@ -2,10 +2,13 @@
22
33There is no other kind, and that is the whole point.
44
5The package runs inside your application, on your servers, against your
6database. Nothing is hosted on our side, nothing is sent anywhere, and the
7licence is checked offline. What follows is therefore not a special deployment
8mode: it is a description of what you already have once you install it.
5The reporting engines run inside your application, on your servers, against
6your database. Reporting sends no rows to ReportsHQ and the licence is checked
7offline. The Laravel package also contains a separate, optional event
8forwarder. That half sends events to an explicitly configured collector only
9when its endpoint, key and sample rate enable it. The collector's future is an
10unmade product decision, so do not assume it is either a required part of
11reporting or permanently retired.
912
1013If you are looking for how to deploy `reportshq.org`, the marketing site and
1114account pages, that is [deploying](/docs/deploy) and it has nothing to do with
@@ -13,27 +16,31 @@ running reports.
1316
1417## What you are actually running
1518
16Your application. That is it.
19Your application. The currently published package versions do not yet include
20the reporting source described here. Check [release status](/docs/quickstart)
21before installation.
1722
1823```bash
1924composer require reportshq/laravel
2025php artisan migrate
2126```
2227
23The package adds routes, views, a console command and five tables. It does not
24add a service, a daemon, a queue worker of its own, or a port to open. If your
25application deploys, the reports deploy with it.
28After a reporting release exists, the Laravel package can load routes, views,
29a console command and five tables. Routes and the JSON API remain off until
30the host enables them. It does not add a service, daemon, queue worker of its
31own, or port to open. Scheduled email uses the host's worker and scheduler.
2632
2733## Requirements
2834
29PHP 8.2+, Laravel 11+, and whatever database you already use. SQL is built for
30whatever driver your connection reports, so anything Laravel supports works,
31including SQLite.
35PHP 8.2+, Laravel 11+, and a supported SQLite, MySQL or Postgres connection.
36The current source has a query dialect for each of those three; do not assume
37every database driver Laravel supports has one.
3238
3339## Without a licence key
3440
35Leave `REPORTSHQ_LICENSE` unset and every limit falls away. Unlimited
36applications, unlimited reports, everything the Pro tier lists.
41Leave `REPORTSHQ_LICENSE` unset and the current reporting engine still runs.
42The plans in pricing copy do not gate functionality or guarantee that every
43listed feature is implemented in both packages.
3744
3845Nothing about the software changes. The pages say the installation is
3946unlicensed and that is the entire difference. This is deliberate: an offline
@@ -49,10 +56,10 @@ paying for, not because something will stop.
4956Worth stating explicitly, because it is the property most self-hosting is
5057chasing.
5158
52Queries run in process, through your own ORM, against your own connection. There
53is no ingest endpoint, no export to a third party, and no telemetry. The licence
54class opens no sockets, and a test asserts that by reading its source for
55anything that could.
59Queries run in process on your own connection, not through ORM scopes. The
60report engine has no ingest endpoint or vendor telemetry. The separate Laravel
61event forwarder can POST taxonomy events to an endpoint the host explicitly
62configures. The licence class opens no sockets.
5663
5764For anyone under a data processing agreement, this is usually the shortest
5865section of a security review you will ever write.
@@ -82,7 +89,8 @@ data was restored with them.
8289
8390## Upgrades
8491
85`composer update reportshq/laravel`, then `php artisan migrate`. Read the
92After a reporting release exists, `composer update reportshq/laravel`, then
93`php artisan migrate`. Read the
8694changelog for anything with a `!` in the commit subject, which is how a breaking
8795change is marked.
8896
docs/sharing.mdmodified+16-16
Changes to docs/sharing.md
@@ -1,9 +1,9 @@
11# Sharing
22
3A share link shows one published report and nothing around it: no navigation
4into the rest of your project, no sign-in wall, no account for the recipient to
5create. The people who most need to see a number are usually the least willing
6to make a login for it.
3The current Laravel source can serve one published report through a revocable
4token, without a recipient account. The current Stacks package has no share
5route in `reportRoutes`, and reportshq.org does not mount one. These instructions
6apply to a Laravel host after a reporting release containing this source exists.
77
88## Creating a link
99
@@ -18,7 +18,7 @@ The link is served by your own application, on your own domain - the route is
1818is hosted here.
1919
2020The token is the credential. Anyone holding it sees that one report, as
21published. They cannot reach anything else in the project, and the link grants
21published. They cannot reach other reports through this route, and the link grants
2222no ability to change anything.
2323
2424## Revoking and rotating
@@ -26,10 +26,9 @@ no ability to change anything.
2626**Revoke** and the link stops working on the next request, not at the end of a
2727cache window.
2828
29**Rotate** and the same report becomes available at a new token while the old
30one dies. That is the honest fix when a URL has been forwarded further than you
31meant: the people who should still have it get the new link, and everyone else
32is simply out.
29There is no dedicated rotate operation in the current API. Revoke the old share
30and create a new one, then distribute the new URL. The API list reveals only a
31token hint, not the complete token.
3332
3433## Views are recorded
3534
@@ -37,16 +36,17 @@ Each view is recorded against the link, so you know whether the weekly report
3736somebody asked for is actually being opened. It settles a lot of arguments
3837about what is worth continuing to produce.
3938
40Embedding is available on the paid tiers, and the top tier removes our footer
41from shared and embedded reports so what you send a client looks like your work.
42See [limits](/docs/limits) for which tier carries what.
39The current reporting package has no embed route or tier-based share guard.
40Pricing copy is not an inventory of shipped link features. See [limits](/docs/limits)
41for how the offline licence behaves.
4342
4443## What a share is not
4544
4645A share link is shared with whoever holds it. It is not a login, there is no
4746per-recipient identity behind it, and it should not be treated as private
48beyond the secrecy of the URL. `robots.txt` keeps `/s/` and `/embed` out of
49search indexes, but a link pasted into a public page is public.
47beyond the secrecy of the URL. The host application is responsible for cache,
48logging and crawler policy for `/reports/shared/{token}`. A link pasted into a
49public page is public.
5050
51If a report should only be seen by named people, add them to the project as
52members instead.
51If a report should be seen only by named people, protect a normal report route
52with the host application's authorization policy instead.
docs/stacks.mdmodified+20-15
Changes to docs/stacks.md
@@ -7,20 +7,25 @@ application already holds, and renders through the routes you already guard.
77Nothing leaves the application. There is no endpoint to send to, no connection
88to hand out, and the licence check is offline.
99
10This is the same engine as [the Laravel package](/docs/laravel) and the same
11compiled chart components, so the two cannot drift on what a number means. What
12differs is only how you describe a model and where you mount it.
10The Stacks and [Laravel source](/docs/laravel) share a result contract and
11compiled chart components. Their query implementations are separate and their
12filter operator vocabularies currently differ. Verify semantics in each
13runtime instead of assuming they cannot drift.
1314
1415## Install
1516
17Do not run that install command yet for the reporting product. npm currently
18serves `0.1.0`, the earlier event-forwarding SDK. The reporting source in this
19checkout is versioned `0.2.0` but has not been published. Verify that npm serves
20a release containing the reporting source before installing it. See the
21[quickstart release status](/docs/quickstart) for the current boundary.
22
23After a reporting release exists, install it with:
24
1625```bash
1726bun add @reportshq/stacks
1827```
1928
20Take `0.2.0` or later. `0.1.0` carries the same name but is a different
21library: it was an event-forwarding SDK from before this package became the
22reporting engine, and none of the API below exists in it.
23
2429**There is no migration, and that is deliberate.** This package ships no tables
2530of its own and never writes to yours: it reads the models you describe below,
2631and it asks the application where reports themselves are kept through a
@@ -86,9 +91,9 @@ rest of `ReportsHQConfig` is optional:
8691}
8792```
8893
89`shareMiddleware` is empty on purpose. A share link is read by somebody with no
90account, so it does not inherit `routes.middleware`; adding a guard there means
91the link stops working for the people it was sent to.
94The config type includes `shareMiddleware`, but the current Stacks
95`reportRoutes` list has no share route. This setting does not publish a link by
96itself. The Laravel source has the share route and its own middleware decision.
9297
9398## Wiring it up
9499
@@ -217,12 +222,12 @@ Or [the JSON API](/docs/api), for a front end of your own.
217222
218223## Exports, sharing, schedules
219224
220CSV and XLSX are generated on demand rather than stored and linked. The numbers
221are one query away, so there is nothing to clean up and no way to serve a stale
222copy.
225The current Stacks package generates CSV on demand. Its download handler
226rejects XLSX. The Laravel source implements both formats. Neither format is
227stored as a report file by the Stacks handler.
223228
224Sharing and scheduling are documented in [sharing](/docs/sharing) and
225[schedules and exports](/docs/schedules-exports).
229Sharing and scheduling in the current source are Laravel features, documented
230in [sharing](/docs/sharing) and [schedules and exports](/docs/schedules-exports).
226231
227232## Requirements
228233
docs/troubleshooting.mdmodified+2-2
Changes to docs/troubleshooting.md
@@ -36,11 +36,11 @@ Confirm the request is reaching the current application release and that the sha
3636
3737## A scheduled report did not arrive
3838
39Check that the scheduler and queue worker are running, the report has an eligible next-run time, mail is configured, recipients are valid and the export format is permitted by the installation's license. Inspect failed jobs before retrying.
39For the current Laravel source, check that the host scheduler and queue worker are running, the report has an eligible next-run time, mail is configured and recipients are valid. The offline licence does not gate export formats. Inspect failed jobs before retrying. The Stacks package has no schedule runner.
4040
4141## XLSX or scheduled delivery is unavailable
4242
43CSV is available on every plan. XLSX and scheduled email are licensed capabilities. See [limits](/docs/limits). License checks are offline and do not disable ordinary report rendering.
43The current Stacks package exports CSV only and has no scheduled delivery. The current Laravel source implements CSV, XLSX and scheduled email, but the published v0.1.0 artifact predates the reporting rewrite. The offline licence does not gate these methods. See [limits](/docs/limits) and [release status](/docs/quickstart).
4444
4545## The docs build succeeds locally but fails during deploy
4646
resources/partials/AppNav.stxmodified+6-6
Changes to resources/partials/AppNav.stx
@@ -5,17 +5,17 @@
55 data. A partial is included into the page's scope, so it reads the page's
66 bindings directly and cannot lose them. --}}
77<header class="bg-panel border-b border-line">
8 <div class="flex gap-4 items-center justify-between mx-auto px-6 py-3 max-w-7xl">
9 <a href="/reports" class="flex gap-6 items-center no-underline">
8 <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:justify-between mx-auto px-4 sm:px-6 py-3 max-w-7xl">
9 <a href="/reports" class="flex gap-6 items-center self-start no-underline">
1010 <Wordmark />
1111 </a>
1212
13 <nav class="flex gap-1 items-center">
14 <a href="/reports" class="px-3 py-1.5 text-muted text-sm hover:text-ink rounded-lg">Reports</a>
15 <a href="/account" class="px-3 py-1.5 text-muted text-sm hover:text-ink rounded-lg">Account</a>
13 <nav class="flex gap-1 items-center justify-between sm:justify-start w-full sm:w-auto">
14 <a href="/reports" class="inline-flex items-center px-3 min-h-11 text-muted text-sm hover:text-ink rounded-lg">Reports</a>
15 <a href="/account" class="inline-flex items-center px-3 min-h-11 text-muted text-sm hover:text-ink rounded-lg">Account</a>
1616 <button
1717 type="button"
18 class="inline-flex items-center justify-center ml-1 h-8 w-8 text-muted border border-line rounded-lg theme-btn"
18 class="inline-flex items-center justify-center ml-1 h-11 w-11 text-muted border border-line rounded-lg theme-btn"
1919 aria-label="Switch theme"
2020 onclick="(function(r){var d=r.getAttribute('data-theme')==='dark';r.setAttribute('data-theme',d?'light':'dark');r.classList.toggle('dark',!d);try{localStorage.setItem('reportshq_theme',d?'light':'dark')}catch(e){}})(document.documentElement)"
2121 >
scripts/validate-visual.tsmodified+17-3
Changes to scripts/validate-visual.ts
@@ -16,6 +16,7 @@ interface PageAudit {
1616 overflow: number
1717 unresolved: boolean
1818 unnamedControls: string[]
19 visibleTextLength: number
1920}
2021
2122interface Pending {
@@ -32,17 +33,20 @@ const OUTPUT = resolve('storage/logs/visual-contract')
3233const VIEWPORTS: Viewport[] = [
3334 { name: 'desktop', width: 1440, height: 1000, mobile: false },
3435 { name: 'tablet', width: 768, height: 1024, mobile: false },
36 { name: 'small-mobile', width: 320, height: 780, mobile: true },
3537 { name: 'mobile', width: 390, height: 844, mobile: true },
38 { name: 'large-mobile', width: 430, height: 932, mobile: true },
3639]
3740const THEMES = ['light', 'dark'] as const
3841const ROUTE_CANDIDATES = [
3942 '/',
43 '/pricing',
44 '/features',
45 '/use-cases',
4046 '/login',
4147 '/register',
42 '/dashboard',
43 '/dashboard/commshq',
4448 '/reports',
45 '/projects',
49 '/account',
4650]
4751
4852function chromePath(): string {
@@ -212,6 +216,7 @@ const auditExpression = `(() => {
212216 overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
213217 unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'),
214218 unnamedControls,
219 visibleTextLength: document.body.innerText.trim().length,
215220 }
216221})()`
217222
@@ -281,6 +286,14 @@ async function main(): Promise<void> {
281286 await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` })
282287 await waitFor(() => cdp.evaluate("document.readyState === 'complete'"))
283288 await Bun.sleep(100)
289 // Protected stx static builds wrap the server-rendered guest branch in a
290 // top-level template. Scripts are disabled in this contract, so expose
291 // that branch for layout inspection instead of capturing a blank page.
292 await cdp.evaluate(`(() => {
293 if (document.body.innerText.trim()) return
294 const shell = document.querySelector('body > template')
295 if (shell) document.body.append(shell.content.cloneNode(true))
296 })()`)
284297 for (const theme of THEMES) {
285298 await cdp.evaluate(`(() => {
286299 const theme = ${JSON.stringify(theme)}
@@ -293,6 +306,7 @@ async function main(): Promise<void> {
293306 const audit = await cdp.evaluate<PageAudit>(auditExpression)
294307 const key = `${route} at ${viewport.name}/${theme}`
295308 if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`)
309 if (audit.visibleTextLength < 20) failures.push(`${key}: no meaningful visible page content`)
296310 if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`)
297311 if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`)
298312 if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`)