create-nevela 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # create-nevela
2
2
 
3
- Create a new [Nevela](https://nevela-docs.vercel.app) app: a Laravel API and a Next.js dashboard, generated from one resource description.
3
+ The `nevela` command. It creates a new [Nevela](https://nevela-docs.vercel.app) app, a Laravel API and a Next.js dashboard generated from one resource description, and runs everything inside one.
4
+
5
+ ```sh
6
+ npm install -g create-nevela # once
7
+ nevela new my-app
8
+ ```
9
+
10
+ Or without installing anything:
4
11
 
5
12
  ```sh
6
13
  pnpm create nevela my-app
@@ -16,41 +23,35 @@ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
16
23
  my-app/
17
24
  ├─ apps/api/ a Laravel app with Sanctum and Nevela installed
18
25
  ├─ apps/web/ the Next.js dashboard, pointed at the API
19
- └─ nevela every command, from this folder: php nevela
26
+ └─ nevela the app's own launcher: php nevela does what nevela does
20
27
  ```
21
28
 
22
29
  Then:
23
30
 
24
31
  ```sh
25
32
  cd my-app
26
- php nevela dev
33
+ nevela dev
27
34
  ```
28
35
 
29
- Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `php artisan nevela:user`.
36
+ Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `nevela user`.
30
37
 
31
38
  The app starts with an administrator and ten sample users (two editors, eight users), in your local database only, so its Users and Roles screens have something to show. Leave the samples out with `--no-sample-users`.
32
39
 
33
40
  Add your first resource, from the same folder:
34
41
 
35
42
  ```sh
36
- php nevela resource Product --fields="name:string, sku:string!, price:money"
43
+ nevela resource Product --fields="name:string, sku:string!, price:money"
37
44
  ```
38
45
 
39
- That writes the Laravel code and the dashboard pages, and creates the table. `php nevela` lists everything else.
46
+ That writes the Laravel code and the dashboard pages, and creates the table. `nevela` on its own lists everything else.
40
47
 
41
- ## The nevela command
42
-
43
- Install this package once and you have a `nevela` command, in every shell:
44
-
45
- ```sh
46
- npm install -g create-nevela
47
- ```
48
+ ## Every command
48
49
 
49
50
  ```sh
50
51
  nevela # anywhere: everything it can do
51
52
  nevela new my-app # anywhere: create an app (leave the name off and it asks)
52
53
  nevela update # anywhere: update the nevela command itself
53
- nevela version # anywhere: the command's version, and the app's when in one
54
+ nevela --version # anywhere: the command's version, and the app's when in one
54
55
  nevela dev # inside an app: run the API and the dashboard
55
56
  nevela upgrade # inside an app: bring it to the latest Nevela
56
57
  ```
@@ -79,7 +80,7 @@ Without the command installed, `npx create-nevela upgrade` does the same, in any
79
80
  | `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
80
81
  | `-y`, `--yes` | Ask nothing. Only the app name is ever asked for. |
81
82
 
82
- With npm, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
83
+ Through npm's `create`, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
83
84
 
84
85
  ## How it installs the Laravel package
85
86
 
package/index.mjs CHANGED
@@ -68,14 +68,14 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
68
68
  nevela new [name] [options] Create an app. It asks for the name if you leave it off.
69
69
  Without the command: pnpm create nevela <name>
70
70
  nevela update Update the nevela command itself to the latest version
71
- nevela version Which version the nevela command is, and this app when in one
71
+ nevela --version Which version the nevela command is, and this app when in one
72
72
 
73
73
  ${bold('Inside an app')}
74
74
  nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
75
75
  nevela dev Run the API and the dashboard
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
- nevela seed | user | generate | version | migrate | artisan <command>
78
+ nevela seed | user | generate | migrate | artisan <command>
79
79
 
80
80
  ${bold('Options')}
81
81
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
@@ -89,7 +89,7 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
89
89
  instead of the release on Packagist.
90
90
  -y, --yes Ask nothing. (Only the app name is ever asked for.)
91
91
  -h, --help Show this.
92
- -v, --version Show the version.
92
+ -v, --version Show the version. Piped or captured, the number alone.
93
93
 
94
94
  ${bold('Needs')} PHP 8.3+, Composer, Node.js 20+
95
95
  ${bold('Docs')} ${DOCS}
@@ -361,6 +361,10 @@ async function inProject(name, args) {
361
361
  }
362
362
  if (name !== 'upgrade') process.exit(forward(project, name, args));
363
363
 
364
+ // The name first, before anything that waits on the network. A newer installer this
365
+ // one hands over to knows it has been shown, and prints one line in its place.
366
+ showTitle();
367
+
364
368
  // Upgrading is the one command that must not run on stale code itself: it is the newest
365
369
  // installer that knows what an older app needs repaired. So even a nevela command that
366
370
  // hasn't been updated in a while upgrades an app correctly.
@@ -371,9 +375,18 @@ async function inProject(name, args) {
371
375
  if (status !== null) process.exit(status);
372
376
  }
373
377
  }
374
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
378
+ const before = appVersion(project);
379
+ const looking = args.includes('--check') ? ' (checking only: nothing will be changed)' : '';
380
+ console.log(` ${indigo(`Nevela upgrade — this app: ${before ? `v${before}` : 'Nevela not installed in it yet'}`)}${dim(looking)}\n`);
375
381
  const releases = await latestRelease();
376
382
  const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
383
+ // Where it ended up, read from the app itself and not from what was asked for.
384
+ if (status === 0 && !args.includes('--check') && !args.includes('--undo')) {
385
+ const now = appVersion(project);
386
+ if (now) {
387
+ console.log(`\n ${green(`✔ ${now === before ? `This app is on Nevela v${now}, the version it was on.` : `This app is now on Nevela v${now}.`}`)}\n`);
388
+ }
389
+ }
377
390
  process.exit(status);
378
391
  }
379
392
 
@@ -445,7 +458,10 @@ function appVersion(project) {
445
458
  }
446
459
  }
447
460
 
448
- /** `nevela version`, from anywhere: the command's version, and the app's when in one. */
461
+ /**
462
+ * `nevela --version`, from anywhere: the command's version, and the app's when in one.
463
+ * `nevela version` is the same thing by its older name.
464
+ */
449
465
  function showVersion() {
450
466
  const project = findProject(process.cwd());
451
467
  const install = installed();
@@ -574,6 +590,13 @@ async function main() {
574
590
  if (argv[0] === 'new') argv.shift();
575
591
  else if (argv[0] === 'update') return updateTool(argv.slice(1));
576
592
  else if (argv[0] === 'version') return showVersion();
593
+ else if ((argv[0] === '--version' || argv[0] === '-v') && argv.length === 1) {
594
+ // To a person, the whole answer. To a script (and to `nevela update`, which asks
595
+ // this of the installed command to check its work), the number and nothing else.
596
+ if (process.stdout.isTTY) return showVersion();
597
+ console.log(VERSION);
598
+ return;
599
+ }
577
600
  else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
578
601
  else if (argv[0] === 'help' || (startedAsNevela && argv.length === 0)) {
579
602
  // `nevela` on its own says what it can do. `pnpm create nevela` on its own starts an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,18 +3,20 @@
3
3
  The Laravel side of [Nevela](https://nevela-docs.vercel.app): describe a resource once and get its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.
4
4
 
5
5
  ```sh
6
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money, kind:enum(stock|digital), notes:text?"
7
- php artisan migrate
6
+ nevela resource Product --fields="name:string, sku:string!, price:money, kind:enum(stock|digital), notes:text?"
8
7
  ```
9
8
 
10
9
  ## Start a new app
11
10
 
12
- The quickest way is the create command, which sets up Laravel, this package and the dashboard together:
11
+ The quickest way is the `nevela` command, which sets up Laravel, this package and the dashboard together:
13
12
 
14
13
  ```sh
15
- pnpm create nevela my-app
14
+ npm install -g create-nevela # once
15
+ nevela new my-app
16
16
  ```
17
17
 
18
+ Without installing it, `pnpm create nevela my-app` does the same.
19
+
18
20
  ## Add it to an existing Laravel app
19
21
 
20
22
  ```sh
@@ -114,6 +114,12 @@ return [
114
114
  'per_page' => 25,
115
115
  'max_per_page' => 100,
116
116
 
117
+ /*
118
+ | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
119
+ | grid). They are saved in one transaction, so this is also how long one can run.
120
+ */
121
+ 'bulk_max' => 500,
122
+
117
123
  /*
118
124
  | File and image fields (image:image, manual:file(pdf)).
119
125
  |
@@ -382,6 +382,8 @@ final class ResourceGenerator
382
382
  foreach ($all as $d) {
383
383
  $controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
384
384
  $lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
385
+ $lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
386
+ $lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
385
387
  $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
386
388
  }
387
389
  $body = implode("\n", $lines);
@@ -749,6 +751,13 @@ final class ResourceGenerator
749
751
  return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
750
752
  }
751
753
 
754
+ public function insights(Request \$request): JsonResponse
755
+ {
756
+ Gate::authorize('viewAny', {$d->name}::class);
757
+
758
+ return Nevela::insights({$d->name}::query(), '{$d->name}', \$request);
759
+ }
760
+
752
761
  public function store({$d->name}Request \$request): JsonResponse
753
762
  {
754
763
  \${$var} = {$d->name}::create(\$request->values());
@@ -759,6 +768,13 @@ final class ResourceGenerator
759
768
  ->header('Location', \$request->url().'/'.\${$var}->getKey());
760
769
  }
761
770
 
771
+ public function bulk(Request \$request): JsonResponse
772
+ {
773
+ Gate::authorize('create', {$d->name}::class);
774
+
775
+ return Nevela::createMany({$d->name}::class, {$d->name}Request::class, {$d->name}Resource::class, \$request);
776
+ }
777
+
762
778
  public function show({$d->name} \${$var}): {$d->name}Resource
763
779
  {
764
780
  Gate::authorize('view', \${$var});
@@ -3,6 +3,8 @@
3
3
  namespace Nevela\Laravel\Http;
4
4
 
5
5
  use Illuminate\Foundation\Http\FormRequest;
6
+ use Illuminate\Support\Facades\Validator as Validation;
7
+ use Illuminate\Validation\Rules\Unique;
6
8
  use Illuminate\Validation\Validator;
7
9
 
8
10
  /**
@@ -52,6 +54,79 @@ abstract class ResourceRequest extends FormRequest
52
54
  });
53
55
  }
54
56
 
57
+ /**
58
+ * Many rows, each checked by the rules for creating one.
59
+ *
60
+ * The same rules as a single create, on purpose: a second way into the table with its
61
+ * own idea of what is valid would let through a row the form then refuses to save.
62
+ * Rows are numbered from 1, as a person counts them.
63
+ *
64
+ * Two rows of one batch can't share a unique value either. The database would refuse
65
+ * the second, but only after the first was written and with no row number to show.
66
+ *
67
+ * @param list<mixed> $rows
68
+ * @return array{values: list<array<string, mixed>>, problems: list<array{row: int, issues: list<array{path: string, message: string}>}>}
69
+ */
70
+ public function many(array $rows): array
71
+ {
72
+ $rules = $this->rules();
73
+ $columns = $this->columns();
74
+ $known = array_keys($columns);
75
+ $computed = array_map(fn (string $field) => $field.'File', $known);
76
+ $unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
77
+
78
+ $values = [];
79
+ $problems = [];
80
+ $taken = [];
81
+ foreach (array_values($rows) as $index => $row) {
82
+ $number = $index + 1;
83
+ if (! is_array($row) || array_is_list($row)) {
84
+ // An empty JSON object arrives as an empty list; anything else here is not fields at all.
85
+ $problems[] = ['row' => $number, 'issues' => [['path' => '', 'message' => $row === [] ? 'This row is empty.' : 'This row is not a record.']]];
86
+
87
+ continue;
88
+ }
89
+ $issues = [];
90
+ foreach (array_keys($row) as $key) {
91
+ if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
92
+ $issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
93
+ }
94
+ }
95
+ $validator = Validation::make($row, $rules);
96
+ if ($validator->fails()) {
97
+ array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
98
+ }
99
+ foreach ($unique as $field) {
100
+ $value = $row[$field] ?? null;
101
+ if (! is_scalar($value) || (string) $value === '') {
102
+ continue;
103
+ }
104
+ $mark = $field."\0".$value;
105
+ if (isset($taken[$mark])) {
106
+ $issues[] = ['path' => $field, 'message' => "The same as row {$taken[$mark]}. Each one needs its own."];
107
+ } else {
108
+ $taken[$mark] = $number;
109
+ }
110
+ }
111
+ if ($issues !== []) {
112
+ $problems[] = ['row' => $number, 'issues' => $issues];
113
+
114
+ continue;
115
+ }
116
+
117
+ $validated = $validator->validated();
118
+ $mapped = [];
119
+ foreach ($columns as $field => $column) {
120
+ if (array_key_exists($field, $validated)) {
121
+ $mapped[$column] = $validated[$field];
122
+ }
123
+ }
124
+ $values[] = $mapped;
125
+ }
126
+
127
+ return ['values' => $values, 'problems' => $problems];
128
+ }
129
+
55
130
  /** Validated input keyed by column, ready for create()/update(). @return array<string, mixed> */
56
131
  public function values(): array
57
132
  {
@@ -3,11 +3,14 @@
3
3
  namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\UniqueConstraintViolationException;
6
7
  use Illuminate\Http\JsonResponse;
7
8
  use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\DB;
8
10
  use InvalidArgumentException;
9
11
  use Nevela\Laravel\Media\Uploads;
10
12
  use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Insights;
11
14
  use Nevela\Laravel\Support\ListQuery;
12
15
  use Nevela\Laravel\Support\Naming;
13
16
 
@@ -18,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
18
21
  final class Nevela
19
22
  {
20
23
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.5.0';
24
+ public const VERSION = '0.5.2';
22
25
 
23
26
  /** @var array<string, Descriptor> */
24
27
  private static array $descriptors = [];
@@ -79,6 +82,35 @@ final class Nevela
79
82
  return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
80
83
  }
81
84
 
85
+ self::narrow($query, $resource, $list);
86
+
87
+ $query->reorder()
88
+ ->orderBy($list->sort['column'], $list->sort['direction'])
89
+ ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
90
+
91
+ $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
92
+
93
+ // One query for every file on the page, instead of one per record.
94
+ foreach ($resource->fields as $field) {
95
+ if ($field->kind === 'file') {
96
+ Uploads::preload($page->getCollection()->pluck($field->column()));
97
+ }
98
+ }
99
+
100
+ return response()->json([
101
+ 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
102
+ 'meta' => [
103
+ 'page' => $page->currentPage(),
104
+ 'perPage' => $page->perPage(),
105
+ 'total' => $page->total(),
106
+ 'totalPages' => max(1, $page->lastPage()),
107
+ ],
108
+ ]);
109
+ }
110
+
111
+ /** The search and the filters of a list request, applied. Its insights are narrowed the same way. */
112
+ private static function narrow(Builder $query, Descriptor $resource, ListQuery $list): void
113
+ {
82
114
  if ($list->q !== null) {
83
115
  $columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
84
116
  if ($columns !== []) {
@@ -96,31 +128,146 @@ final class Nevela
96
128
  ? $query->whereNull($filter['column'])
97
129
  : $query->where($filter['column'], $filter['value']);
98
130
  }
131
+ }
99
132
 
100
- $query->reorder()
101
- ->orderBy($list->sort['column'], $list->sort['direction'])
102
- ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
133
+ /**
134
+ * GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
135
+ *
136
+ * What the dashboard's insights panel draws: how many records were created in each
137
+ * period, and how they split across each field that is a choice (an enum or a yes/no).
138
+ *
139
+ * It takes the list's own search and filters, so the charts describe the rows the
140
+ * table is showing. A whole-table chart above a filtered table would be wrong in a
141
+ * prominent place.
142
+ */
143
+ public static function insights(Builder $query, string $name, Request $request): JsonResponse
144
+ {
145
+ $resource = self::resource($name);
146
+ $unit = $request->query('unit', 'day');
147
+ if (! is_string($unit) || ! isset(Insights::PERIODS[$unit])) {
148
+ return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'unit', 'message' => 'Must be day, week or month.']]], 400);
149
+ }
150
+ // The same parameters as the list. Where in the list someone is makes no difference to a count.
151
+ $params = array_diff_key($request->query(), array_flip(['unit', 'page', 'perPage', 'sort', 'columns']));
152
+ $list = ListQuery::parse($resource, $params, (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
153
+ if (! $list->ok()) {
154
+ return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
155
+ }
156
+ self::narrow($query, $resource, $list);
157
+ $base = fn () => (clone $query)->reorder();
103
158
 
104
- $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
159
+ // Counted per day by the database, one grouped query over the created_at index,
160
+ // and added up into weeks or months here: see Support\Insights.
161
+ $now = now();
162
+ $table = $query->getModel()->getTable();
163
+ $day = $query->getConnection()->getDriverName() === 'sqlsrv' ? "CAST({$table}.created_at AS date)" : "DATE({$table}.created_at)";
164
+ $perDay = $base()->toBase()
165
+ ->where("{$table}.created_at", '>=', Insights::since($unit, $now))
166
+ ->selectRaw("{$day} as day, count(*) as aggregate")
167
+ ->groupByRaw($day)
168
+ ->get()
169
+ ->map(fn ($row) => [(string) $row->day, (int) $row->aggregate]);
105
170
 
106
- // One query for every file on the page, instead of one per record.
171
+ $breakdown = [];
107
172
  foreach ($resource->fields as $field) {
108
- if ($field->kind === 'file') {
109
- Uploads::preload($page->getCollection()->pluck($field->column()));
173
+ if (! in_array($field->kind, ['enum', 'boolean'], true)) {
174
+ continue;
175
+ }
176
+ if (count($breakdown) === 4) {
177
+ break; // one grouped query each, and four charts is already a lot to read
110
178
  }
179
+ $column = $field->column();
180
+ $counts = [];
181
+ foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
182
+ $value = $row->value === null ? '' : ($field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value);
183
+ $counts[$value] = ($counts[$value] ?? 0) + (int) $row->aggregate;
184
+ }
185
+ // Every choice the field has, in its own order, so one nobody has picked shows as zero.
186
+ $values = $field->kind === 'boolean' ? ['true', 'false'] : $field->options;
187
+ $slices = array_map(fn (string $value) => ['value' => $value, 'count' => $counts[$value] ?? 0], $values);
188
+ if (($counts[''] ?? 0) > 0) {
189
+ $slices[] = ['value' => '', 'count' => $counts['']];
190
+ }
191
+ $breakdown[] = ['field' => $field->name, 'kind' => $field->kind, 'slices' => $slices];
111
192
  }
112
193
 
113
194
  return response()->json([
114
- 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
115
- 'meta' => [
116
- 'page' => $page->currentPage(),
117
- 'perPage' => $page->perPage(),
118
- 'total' => $page->total(),
119
- 'totalPages' => max(1, $page->lastPage()),
120
- ],
195
+ 'total' => $base()->count(),
196
+ 'unit' => $unit,
197
+ 'series' => Insights::series($unit, $now, $perDay),
198
+ 'breakdown' => $breakdown,
121
199
  ]);
122
200
  }
123
201
 
202
+ /**
203
+ * POST /{slug}/_bulk { items: [ … ] } → 201 { created, data }, or 422 { error, rows }.
204
+ *
205
+ * Several records in one go, for the dashboard's grid. Every row is checked before
206
+ * anything is written, and they are saved in one transaction: a mistake on row 7
207
+ * is reported against row 7 and nothing is created, where saving six and stopping
208
+ * would leave someone working out which six.
209
+ *
210
+ * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
211
+ * @param class-string<Http\ResourceRequest> $requestClass
212
+ * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
213
+ */
214
+ public static function createMany(string $modelClass, string $requestClass, string $resourceClass, Request $request): JsonResponse
215
+ {
216
+ $most = max(1, (int) config('nevela.bulk_max', 500));
217
+ $items = $request->input('items');
218
+ if (! is_array($items) || ! array_is_list($items) || $items === []) {
219
+ return response()->json(['error' => 'Send the rows to create as "items", a list with at least one row.'], 422);
220
+ }
221
+ if (count($items) > $most) {
222
+ return response()->json(['error' => 'That is '.number_format(count($items))." rows. At most {$most} can be created at once; for more, import a file."], 422);
223
+ }
224
+
225
+ $checked = (new $requestClass)->many($items);
226
+ if ($checked['problems'] !== []) {
227
+ return self::rowsRefused($checked['problems'], count($items));
228
+ }
229
+
230
+ try {
231
+ $created = DB::transaction(function () use ($modelClass, $checked) {
232
+ $created = [];
233
+ foreach ($checked['values'] as $index => $values) {
234
+ try {
235
+ $created[] = $modelClass::create($values);
236
+ } catch (UniqueConstraintViolationException $e) {
237
+ // Someone else created the same value between the check and the write.
238
+ throw new \RuntimeException((string) ($index + 1), 0, $e);
239
+ }
240
+ }
241
+
242
+ return $created;
243
+ });
244
+ } catch (\RuntimeException $e) {
245
+ if (! $e->getPrevious() instanceof UniqueConstraintViolationException) {
246
+ throw $e;
247
+ }
248
+
249
+ return self::rowsRefused([['row' => (int) $e->getMessage(), 'issues' => [['path' => '', 'message' => 'A record with one of these values was created a moment ago.']]]], count($items));
250
+ }
251
+
252
+ return response()->json([
253
+ 'created' => count($created),
254
+ 'data' => array_map(fn ($model) => (new $resourceClass($model->refresh()))->resolve($request), $created),
255
+ ], 201);
256
+ }
257
+
258
+ /** @param list<array{row: int, issues: list<array{path: string, message: string}>}> $problems */
259
+ private static function rowsRefused(array $problems, int $sent): JsonResponse
260
+ {
261
+ $wrong = count($problems);
262
+
263
+ return response()->json([
264
+ 'error' => $wrong === 1
265
+ ? "Nothing was created: row {$problems[0]['row']} needs fixing."
266
+ : "Nothing was created: {$wrong} of the {$sent} rows need fixing.",
267
+ 'rows' => $problems,
268
+ ], 422);
269
+ }
270
+
124
271
  /**
125
272
  * GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
126
273
  * What Flare's ResourceStats card shows above a table.
@@ -0,0 +1,78 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Carbon\CarbonImmutable;
6
+ use Carbon\CarbonInterface;
7
+
8
+ /**
9
+ * The periods a "created per day, week or month" chart is drawn in.
10
+ *
11
+ * The database is only ever asked for counts per day, which every database can give
12
+ * with the same expression. Weeks and months are added up here, so a week starts on
13
+ * Monday and a month is a calendar month whichever database is underneath.
14
+ */
15
+ final class Insights
16
+ {
17
+ /** How many periods of each kind a chart shows: a month of days, half a year of weeks, a year of months. */
18
+ public const PERIODS = ['day' => 30, 'week' => 26, 'month' => 12];
19
+
20
+ /**
21
+ * The periods up to and including the one `$now` is in, oldest first.
22
+ *
23
+ * @return list<string> A day or a week as "2026-10-05" (a week by its Monday), a month as "2026-10"
24
+ */
25
+ public static function buckets(string $unit, CarbonInterface $now): array
26
+ {
27
+ $current = CarbonImmutable::instance($now)->startOfDay();
28
+ $buckets = [];
29
+ for ($back = self::PERIODS[$unit] - 1; $back >= 0; $back--) {
30
+ $buckets[] = match ($unit) {
31
+ 'day' => $current->subDays($back)->format('Y-m-d'),
32
+ 'week' => $current->startOfWeek(CarbonInterface::MONDAY)->subWeeks($back)->format('Y-m-d'),
33
+ // From the first of the month: stepping back from the 31st would skip the short months.
34
+ 'month' => $current->startOfMonth()->subMonthsNoOverflow($back)->format('Y-m'),
35
+ };
36
+ }
37
+
38
+ return $buckets;
39
+ }
40
+
41
+ /** The first moment the chart covers. */
42
+ public static function since(string $unit, CarbonInterface $now): CarbonImmutable
43
+ {
44
+ $first = self::buckets($unit, $now)[0];
45
+
46
+ return CarbonImmutable::parse($unit === 'month' ? "{$first}-01" : $first, $now->getTimezone())->startOfDay();
47
+ }
48
+
49
+ /** Which period a day ("2026-10-09") belongs to. */
50
+ public static function bucketOf(string $unit, string $day): string
51
+ {
52
+ return match ($unit) {
53
+ 'day' => substr($day, 0, 10),
54
+ 'week' => CarbonImmutable::parse(substr($day, 0, 10))->startOfWeek(CarbonInterface::MONDAY)->format('Y-m-d'),
55
+ 'month' => substr($day, 0, 7),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Counts per day, added up into the chart's periods. A period with nothing in it is
61
+ * still there, as zero: a gap in a chart should look like a gap.
62
+ *
63
+ * @param iterable<array{0: string, 1: int}> $perDay Pairs of day and count
64
+ * @return list<array{bucket: string, count: int}>
65
+ */
66
+ public static function series(string $unit, CarbonInterface $now, iterable $perDay): array
67
+ {
68
+ $counts = array_fill_keys(self::buckets($unit, $now), 0);
69
+ foreach ($perDay as [$day, $count]) {
70
+ $bucket = self::bucketOf($unit, $day);
71
+ if (isset($counts[$bucket])) {
72
+ $counts[$bucket] += $count;
73
+ }
74
+ }
75
+
76
+ return array_map(fn (string $bucket, int $count) => ['bucket' => $bucket, 'count' => $count], array_keys($counts), array_values($counts));
77
+ }
78
+ }