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.
@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
4
4
  import { storedFields, type PolicyAction } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { toCsv } from "@/lib/csv";
7
+ import { revalidateResource } from "@/lib/cache";
7
8
  import { fileUrl } from "@/lib/files";
9
+ import { laravel } from "@/lib/laravel";
8
10
  import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
9
11
 
10
12
  export type ActionResult<T = unknown> =
@@ -103,6 +105,83 @@ export async function createRecordAction(resourceName: string, input: unknown):
103
105
  return result;
104
106
  }
105
107
 
108
+ export type InsightsUnit = "day" | "week" | "month";
109
+
110
+ export interface InsightsData {
111
+ /** Everything the list matches, whenever it was created. */
112
+ total: number;
113
+ unit: InsightsUnit;
114
+ /** How many were created in each period, oldest first. A day or a week is "2026-10-05" (a week by its Monday), a month "2026-10". */
115
+ series: { bucket: string; count: number }[];
116
+ /** How the records split across each field that is a choice. */
117
+ breakdown: { field: string; kind: "enum" | "boolean"; slices: { value: string; count: number }[] }[];
118
+ }
119
+
120
+ /**
121
+ * The counts behind the insights panel: `GET /{slug}/_insights`.
122
+ *
123
+ * `query` is the list's own search and filters, so the charts are of the rows the table
124
+ * is showing. Asked for only when the panel is opened.
125
+ */
126
+ export async function resourceInsightsAction(resourceName: string, query: string, unit: InsightsUnit): Promise<ActionResult<InsightsData>> {
127
+ const denied = await allowed(resourceName, "read");
128
+ if (denied) return denied;
129
+
130
+ const { resource } = dashboardStore(resourceName);
131
+ const params = new URLSearchParams();
132
+ for (const [key, value] of new URLSearchParams(query)) if (key === "q" || key.startsWith("filter[")) params.append(key, value);
133
+ params.set("unit", unit);
134
+ const response = await laravel(`${resource.slug}/_insights?${params}`);
135
+ if (!response.ok) {
136
+ const body = (response.body ?? {}) as { error?: string };
137
+ // An app whose Laravel side is from before this existed has no such address.
138
+ const missing = response.status === 404 || response.status === 405;
139
+ return { ok: false, status: response.status, error: missing ? "This app's API has no insights yet. Run: nevela upgrade" : (body.error ?? "Request failed.") };
140
+ }
141
+ return { ok: true, data: response.body as InsightsData };
142
+ }
143
+
144
+ /** What was wrong with one row of several, numbered from 1 among the rows that were sent. */
145
+ export interface RowProblem {
146
+ row: number;
147
+ issues: FieldIssue[];
148
+ }
149
+
150
+ export type CreateManyResult = { ok: true; data: { created: number } } | { ok: false; status: number; error: string; rows?: RowProblem[] };
151
+
152
+ /**
153
+ * Several records at once, for the "Add several" grid: `POST /{slug}/_bulk`.
154
+ *
155
+ * Laravel checks every row with the rules it uses for one, and saves them in a single
156
+ * transaction. So the answer is all of them, or none and the rows that were wrong.
157
+ */
158
+ export async function createManyAction(resourceName: string, rows: Record<string, unknown>[]): Promise<CreateManyResult> {
159
+ const denied = await allowed(resourceName, "create");
160
+ if (denied) return denied;
161
+ if (rows.length === 0) return { ok: false, status: 400, error: "Nothing to create: every row is empty." };
162
+
163
+ const { resource } = dashboardStore(resourceName);
164
+ const response = await laravel(`${resource.slug}/_bulk`, { method: "POST", body: { items: rows } });
165
+ const body = (response.body ?? {}) as { error?: string; rows?: RowProblem[]; created?: number; data?: { id?: unknown }[] };
166
+ if (!response.ok) {
167
+ return {
168
+ ok: false,
169
+ status: response.status,
170
+ // An app whose Laravel side is from before this existed answers 404 or 405.
171
+ error: response.status === 404 || response.status === 405 ? "This app's API can't create several at once yet. Run: nevela upgrade" : (body.error ?? "Request failed."),
172
+ ...(body.rows ? { rows: body.rows } : {}),
173
+ };
174
+ }
175
+
176
+ try {
177
+ revalidateResource({ resource: resource.name, action: "create", id: String(body.data?.[0]?.id ?? "") });
178
+ } catch (error) {
179
+ console.error(`[nevela] ${resource.name} bulk create cache invalidation failed:`, error);
180
+ }
181
+ revalidatePath(resourcePath(resource));
182
+ return { ok: true, data: { created: body.created ?? rows.length } };
183
+ }
184
+
106
185
  export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
107
186
  const denied = await allowed(resourceName, "update");
108
187
  if (denied) return denied;
@@ -114,15 +114,9 @@ export default function Home() {
114
114
  <p>
115
115
  © {new Date().getFullYear()} {site.name}
116
116
  </p>
117
- {/* Delete this line when the app is your own — it's here so whoever set the
118
- project up can find what running it actually costs. */}
119
- <a
120
- href="https://flare-docs.codetotech.com/guides/costs/"
121
- target="_blank"
122
- rel="noreferrer"
123
- className="hover:text-foreground"
124
- >
125
- Built with Flare · what it costs to run
117
+ {/* Delete this line when the app is your own. */}
118
+ <a href="https://nevela-docs.vercel.app" target="_blank" rel="noreferrer" className="hover:text-foreground">
119
+ Built with Nevela (Next.js + Laravel)
126
120
  </a>
127
121
  <nav aria-label="Footer" className="flex gap-6">
128
122
  <Link href={site.links.terms} className="hover:text-foreground">
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useMemo, useRef, useState, useTransition } from "react";
5
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { ListPlusIcon, PlusIcon, XIcon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { createManyAction } from "@/app/dashboard/actions";
9
+ import { Alert, AlertDescription } from "@/components/ui/alert";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Spinner } from "@/components/ui/spinner";
15
+ import { cn } from "@/lib/utils";
16
+ import { FieldWidget, type RelationMeta } from "./fields/field-widget";
17
+
18
+ type FieldDef = StoredField & { label: string };
19
+ type Values = Record<string, string | boolean>;
20
+ type Relations = Record<string, RelationMeta & { initialTitle?: string }>;
21
+
22
+ interface Row {
23
+ /** Stays with the row as others are added and removed around it. */
24
+ key: number;
25
+ values: Values;
26
+ }
27
+
28
+ /**
29
+ * Enough to see that more can be added, and few enough not to look like a form to fill
30
+ * in. An empty grid gives nothing to type into; one row means clicking "Add row" before
31
+ * you have begun.
32
+ */
33
+ const STARTING_ROWS = 5;
34
+
35
+ /**
36
+ * Where the grid stops adding rows: what Laravel accepts in one request unless an app
37
+ * changes it (config/nevela.php, bulk_max). An app that sets it lower is still safe:
38
+ * Laravel refuses the larger request, and its message, which says the limit, is shown.
39
+ */
40
+ const MOST_ROWS = 500;
41
+
42
+ /** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
43
+ const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
44
+
45
+ /**
46
+ * Add several records at once, in a grid.
47
+ *
48
+ * Between "New" and "Import". Import is for a file somebody already has, and New is for
49
+ * one record. This is for the twelve you have in your head or on a piece of paper, which
50
+ * until now meant opening the form twelve times.
51
+ *
52
+ * Rows left empty are ignored, so nobody has to delete the ones they didn't use. The rows
53
+ * that were filled in are saved together or not at all: Laravel checks every one first,
54
+ * and a mistake comes back against its row with nothing created.
55
+ */
56
+ export function BulkCreateButton({ resource, relations = {} }: { resource: ClientResource; relations?: Relations }) {
57
+ const [open, setOpen] = useState(false);
58
+ const plural = resource.pluralLabel.toLowerCase();
59
+
60
+ return (
61
+ <Dialog open={open} onOpenChange={setOpen}>
62
+ <DialogTrigger asChild>
63
+ <Button variant="outline">
64
+ <ListPlusIcon data-icon="inline-start" />
65
+ Add several
66
+ </Button>
67
+ </DialogTrigger>
68
+ <DialogContent className="flex max-h-[90vh] flex-col gap-4 sm:max-w-[min(96vw,84rem)]">
69
+ <DialogHeader>
70
+ <DialogTitle>Add several {plural}</DialogTitle>
71
+ <DialogDescription>One row for each. Rows you leave empty are ignored, and they are all saved together or not at all.</DialogDescription>
72
+ </DialogHeader>
73
+ {/* Mounted only while open, so what was typed and abandoned last time is gone. */}
74
+ {open && <Grid resource={resource} relations={relations} onDone={() => setOpen(false)} />}
75
+ </DialogContent>
76
+ </Dialog>
77
+ );
78
+ }
79
+
80
+ function Grid({ resource, relations, onDone }: { resource: ClientResource; relations: Relations; onDone: () => void }) {
81
+ const router = useRouter();
82
+ const [pending, startTransition] = useTransition();
83
+ const validators = useMemo(() => createValidators(resource), [resource]);
84
+ const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
85
+ const fields = useMemo(() => all.filter(([, def]) => IN_A_CELL.has(def.kind)), [all]);
86
+ // A field this grid can't show and Laravel requires would fail every row, with nothing
87
+ // on screen to fix. Say so, and leave those records to the form.
88
+ const missing = useMemo(() => all.filter(([, def]) => !IN_A_CELL.has(def.kind) && def.required !== false), [all]);
89
+
90
+ const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
91
+ const nextKey = useRef(STARTING_ROWS);
92
+ const [rows, setRows] = useState<Row[]>(() => Array.from({ length: STARTING_ROWS }, (_, key) => ({ key, values: { ...blank } })));
93
+ /** Problems by row key, then by field. "" holds what isn't about one field. */
94
+ const [problems, setProblems] = useState<Record<number, Record<string, string>>>({});
95
+ const [error, setError] = useState<string | null>(null);
96
+
97
+ // A row is empty when it is exactly as it started: a tick box left unticked is not an entry.
98
+ const isEmpty = (values: Values) => fields.every(([key]) => values[key] === blank[key]);
99
+ const filled = rows.filter((row) => !isEmpty(row.values));
100
+ const noun = filled.length === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase();
101
+
102
+ function change(rowKey: number, field: string, value: string | boolean) {
103
+ setRows((current) => current.map((row) => (row.key === rowKey ? { ...row, values: { ...row.values, [field]: value } } : row)));
104
+ if (problems[rowKey]?.[field]) {
105
+ setProblems(({ [rowKey]: row = {}, ...rest }) => {
106
+ const { [field]: _fixed, ...others } = row;
107
+ return Object.keys(others).length > 0 ? { ...rest, [rowKey]: others } : rest;
108
+ });
109
+ }
110
+ }
111
+
112
+ function addRows(count: number) {
113
+ setRows((current) => [...current, ...Array.from({ length: Math.min(count, MOST_ROWS - current.length) }, () => ({ key: nextKey.current++, values: { ...blank } }))]);
114
+ }
115
+
116
+ function removeRow(rowKey: number) {
117
+ setRows((current) => (current.length > 1 ? current.filter((row) => row.key !== rowKey) : [{ key: nextKey.current++, values: { ...blank } }]));
118
+ setProblems(({ [rowKey]: _gone, ...rest }) => rest);
119
+ }
120
+
121
+ function save() {
122
+ setError(null);
123
+ if (filled.length === 0) {
124
+ setError("Nothing to create yet: every row is empty.");
125
+ return;
126
+ }
127
+ // Checked here first with the descriptor's own rules, so most mistakes never leave the page.
128
+ const found: Record<number, Record<string, string>> = {};
129
+ const inputs = filled.map((row) => {
130
+ const input = formValuesToInput(resource, row.values, "create") as Record<string, unknown>;
131
+ const parsed = validators.create.safeParse(input);
132
+ if (!parsed.success) {
133
+ const visible = Object.fromEntries(Object.entries(issuesByField(parsed.error.issues)).filter(([key]) => fields.some(([field]) => field === key)));
134
+ if (Object.keys(visible).length > 0) found[row.key] = visible;
135
+ }
136
+ // Only what the grid shows is sent; a field it can't show is left for Laravel to judge.
137
+ return Object.fromEntries(Object.entries(input).filter(([key]) => fields.some(([field]) => field === key)));
138
+ });
139
+ if (Object.keys(found).length > 0) {
140
+ setProblems(found);
141
+ setError(Object.keys(found).length === 1 ? "One row needs fixing." : `${Object.keys(found).length} rows need fixing.`);
142
+ return;
143
+ }
144
+
145
+ startTransition(async () => {
146
+ const result = await createManyAction(resource.name, inputs);
147
+ if (result.ok) {
148
+ toast.success(`${result.data.created} ${result.data.created === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()} created.`);
149
+ onDone();
150
+ router.refresh();
151
+ return;
152
+ }
153
+ // Laravel numbers the rows it was sent, which are only the ones filled in. Here a
154
+ // row is called by its number on screen, so every number it mentions is translated.
155
+ const onScreen = (sent: number) => {
156
+ const row = filled[sent - 1];
157
+ return row ? rows.findIndex((candidate) => candidate.key === row.key) + 1 : sent;
158
+ };
159
+ const fromServer: Record<number, Record<string, string>> = {};
160
+ const numbers: number[] = [];
161
+ for (const problem of result.rows ?? []) {
162
+ const row = filled[problem.row - 1];
163
+ if (!row) continue;
164
+ numbers.push(onScreen(problem.row));
165
+ fromServer[row.key] = {};
166
+ for (const issue of problem.issues) {
167
+ const field = issue.path.split(".")[0] ?? "";
168
+ const known = fields.some(([key]) => key === field);
169
+ const label = all.find(([key]) => key === field)?.[1].label;
170
+ const message = issue.message.replace(/\brow (\d+)\b/g, (_, sent: string) => `row ${onScreen(Number(sent))}`);
171
+ // A problem with a field that has no column here is said beside the row instead.
172
+ fromServer[row.key]![known ? field : ""] ??= known || !label ? message : `${label}: ${message}`;
173
+ }
174
+ }
175
+ setProblems(fromServer);
176
+ setError(numbers.length === 0 ? result.error : numbers.length === 1 ? `Nothing was created: row ${numbers[0]} needs fixing.` : `Nothing was created: ${numbers.length} rows need fixing.`);
177
+ });
178
+ }
179
+
180
+ return (
181
+ <>
182
+ {missing.length > 0 && (
183
+ <Alert>
184
+ <AlertDescription>
185
+ {missing.map(([, def]) => def.label).join(", ")} {missing.length === 1 ? "is" : "are"} required and can&apos;t be filled in here, so these rows would be
186
+ refused. Use New {resource.label.toLowerCase()} for {resource.pluralLabel.toLowerCase()} instead.
187
+ </AlertDescription>
188
+ </Alert>
189
+ )}
190
+
191
+ <div className="min-h-0 flex-1 overflow-auto rounded-md border">
192
+ <table className="w-full border-collapse text-sm">
193
+ <thead className="sticky top-0 z-10 bg-muted text-left text-muted-foreground">
194
+ <tr>
195
+ <th scope="col" className="w-10 px-2 py-2 text-center font-medium">
196
+ #
197
+ </th>
198
+ {fields.map(([key, def]) => (
199
+ <th key={key} scope="col" className={cn("px-2 py-2 font-medium whitespace-nowrap", def.kind === "boolean" ? "w-24 text-center" : "min-w-44")}>
200
+ {def.label}
201
+ {def.required !== false && def.kind !== "boolean" && (
202
+ <span className="text-destructive" aria-label="required">
203
+ {" "}
204
+ *
205
+ </span>
206
+ )}
207
+ </th>
208
+ ))}
209
+ <th scope="col" className="w-10">
210
+ <span className="sr-only">Remove</span>
211
+ </th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ {rows.map((row, index) => {
216
+ const wrong = problems[row.key] ?? {};
217
+ const messages = Object.entries(wrong).map(([field, message]) => {
218
+ const label = fields.find(([key]) => key === field)?.[1].label;
219
+ return label ? `${label}: ${message}` : message;
220
+ });
221
+ return (
222
+ <tr key={row.key} className="border-t align-top">
223
+ <td className="px-2 py-3 text-center text-muted-foreground tabular-nums">{index + 1}</td>
224
+ {fields.map(([key, def]) => (
225
+ <td key={key} className={cn("px-1.5 py-1.5", def.kind === "boolean" && "text-center")}>
226
+ <Cell
227
+ id={`bulk-${row.key}-${key}`}
228
+ resourceName={resource.name}
229
+ fieldKey={key}
230
+ field={def}
231
+ label={`${def.label}, row ${index + 1}`}
232
+ value={row.values[key] ?? ""}
233
+ invalid={Boolean(wrong[key])}
234
+ disabled={pending}
235
+ relation={relations[key]}
236
+ onChange={(value) => change(row.key, key, value)}
237
+ />
238
+ {/* Said under the cell it is about. One with no cell of its own goes under the first. */}
239
+ {key === fields[0]?.[0] && messages.length > 0 && (
240
+ <p role="alert" className="mt-1 w-max max-w-[60vw] text-left text-xs text-destructive">
241
+ {messages.join(" · ")}
242
+ </p>
243
+ )}
244
+ </td>
245
+ ))}
246
+ <td className="px-1 py-1.5">
247
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove row ${index + 1}`} disabled={pending} onClick={() => removeRow(row.key)}>
248
+ <XIcon />
249
+ </Button>
250
+ </td>
251
+ </tr>
252
+ );
253
+ })}
254
+ </tbody>
255
+ </table>
256
+ </div>
257
+
258
+ <div className="flex flex-wrap items-center gap-2">
259
+ <Button type="button" variant="outline" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(1)}>
260
+ <PlusIcon data-icon="inline-start" />
261
+ Add row
262
+ </Button>
263
+ <Button type="button" variant="ghost" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(5)}>
264
+ Add 5 rows
265
+ </Button>
266
+ {error && (
267
+ <p role="alert" className="text-sm text-destructive">
268
+ {error}
269
+ </p>
270
+ )}
271
+ </div>
272
+
273
+ <DialogFooter>
274
+ <Button type="button" variant="ghost" disabled={pending} onClick={onDone}>
275
+ Cancel
276
+ </Button>
277
+ <Button type="button" disabled={pending || filled.length === 0 || missing.length > 0} onClick={save}>
278
+ {pending && <Spinner />}
279
+ {filled.length === 0 ? `Create ${resource.pluralLabel.toLowerCase()}` : `Create ${filled.length} ${noun}`}
280
+ </Button>
281
+ </DialogFooter>
282
+ </>
283
+ );
284
+ }
285
+
286
+ /**
287
+ * One cell. Long text is a single line here and a tick box is a tick box: a grid is for
288
+ * typing across, and a four-line text area or a labelled switch in every row is neither
289
+ * quick nor readable. Everything else is the form's own control, so a date, a price or a
290
+ * link to another record behaves exactly as it does there.
291
+ */
292
+ function Cell({
293
+ id,
294
+ resourceName,
295
+ fieldKey,
296
+ field,
297
+ label,
298
+ value,
299
+ invalid,
300
+ disabled,
301
+ relation,
302
+ onChange,
303
+ }: {
304
+ id: string;
305
+ resourceName: string;
306
+ fieldKey: string;
307
+ field: FieldDef;
308
+ label: string;
309
+ value: string | boolean;
310
+ invalid: boolean;
311
+ disabled: boolean;
312
+ relation?: RelationMeta & { initialTitle?: string };
313
+ onChange: (value: string | boolean) => void;
314
+ }) {
315
+ if (field.kind === "boolean") {
316
+ return <Checkbox id={id} aria-label={label} checked={value === true} disabled={disabled} onCheckedChange={(checked) => onChange(checked === true)} className="mt-2" />;
317
+ }
318
+ if (field.kind === "text") {
319
+ return <Input id={id} aria-label={label} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={invalid || undefined} onChange={(event) => onChange(event.target.value)} />;
320
+ }
321
+ return (
322
+ <div aria-label={label} role="group">
323
+ <FieldWidget id={id} name={id} resourceName={resourceName} fieldKey={fieldKey} field={field} value={value} onChange={onChange} invalid={invalid} disabled={disabled} relation={relation} />
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { useSearchParams } from "next/navigation";
4
+ import { useEffect, useId, useMemo, useState } from "react";
5
+ import { optionLabel, type ClientResource } from "@flaredev/core";
6
+ import { BarChart3Icon, ChevronDownIcon } from "lucide-react";
7
+ import { resourceInsightsAction, type InsightsData, type InsightsUnit } from "@/app/dashboard/actions";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ const UNITS: { key: InsightsUnit; label: string; per: string; span: string }[] = [
12
+ { key: "day", label: "Daily", per: "day", span: "the last 30 days" },
13
+ { key: "week", label: "Weekly", per: "week", span: "the last 26 weeks" },
14
+ { key: "month", label: "Monthly", per: "month", span: "the last 12 months" },
15
+ ];
16
+
17
+ /** A period as someone would say it: "9 Oct", "the week of 5 Oct", "Oct 2026". */
18
+ function periodLabel(unit: InsightsUnit, bucket: string, long = false): string {
19
+ // UTC throughout: a bucket is a calendar day or month, not a moment, and must not slide a day in another timezone.
20
+ const date = new Date(`${unit === "month" ? `${bucket}-01` : bucket}T00:00:00Z`);
21
+ if (unit === "month") return new Intl.DateTimeFormat("en-GB", { month: "short", year: "numeric", timeZone: "UTC" }).format(date);
22
+ const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(long ? { year: "numeric" } : {}), timeZone: "UTC" }).format(date);
23
+ return unit === "week" && long ? `the week of ${day}` : day;
24
+ }
25
+
26
+ /**
27
+ * Charts above a list, closed until somebody wants them.
28
+ *
29
+ * Closed is the default on purpose. The panel's numbers are a grouped query each over
30
+ * everything the list matches, which is work an ordinary visit to a list doesn't need.
31
+ * So nothing is asked for until it is opened, and whether it was left open is remembered
32
+ * for the next visit.
33
+ *
34
+ * What it charts is the rows the table is showing: the same search and filters go on the
35
+ * request, so narrowing the list to one category redraws the charts for that category.
36
+ *
37
+ * Drawn with plain elements and no chart library: bars are all these are, and a library
38
+ * for bars would be several hundred kilobytes to draw rectangles.
39
+ */
40
+ export function ResourceInsights({ resource, total }: { resource: ClientResource; /** The list's own count: when it changes, so have the charts. */ total: number }) {
41
+ const panel = useId();
42
+ const remembered = `nevela.insights.${resource.name}`;
43
+ const [open, setOpen] = useState(false);
44
+ const [unit, setUnit] = useState<InsightsUnit>("day");
45
+ const [data, setData] = useState<InsightsData | null>(null);
46
+ const [loading, setLoading] = useState(false);
47
+ const [error, setError] = useState<string | null>(null);
48
+
49
+ // Only what narrows the list. A page number or a sort order changes no count.
50
+ const params = useSearchParams();
51
+ const narrowed = useMemo(() => {
52
+ const kept = new URLSearchParams();
53
+ for (const [key, value] of params) if (key === "q" || key.startsWith("filter[")) kept.append(key, value);
54
+ kept.sort();
55
+ return kept.toString();
56
+ }, [params]);
57
+
58
+ // Read after the first paint: the server can't know what this browser remembers.
59
+ useEffect(() => {
60
+ try {
61
+ if (window.localStorage.getItem(remembered) === "open") setOpen(true);
62
+ } catch {
63
+ // No storage (a private window, or it is switched off): it just starts closed.
64
+ }
65
+ }, [remembered]);
66
+
67
+ function toggle() {
68
+ const next = !open;
69
+ setOpen(next);
70
+ try {
71
+ window.localStorage.setItem(remembered, next ? "open" : "closed");
72
+ } catch {
73
+ // Not remembered, and that is all.
74
+ }
75
+ }
76
+
77
+ useEffect(() => {
78
+ if (!open) return;
79
+ let current = true;
80
+ setLoading(true);
81
+ void resourceInsightsAction(resource.name, narrowed, unit).then((result) => {
82
+ if (!current) return;
83
+ setLoading(false);
84
+ if (result.ok) {
85
+ setData(result.data);
86
+ setError(null);
87
+ } else {
88
+ setError(result.error);
89
+ }
90
+ });
91
+ return () => {
92
+ current = false;
93
+ };
94
+ }, [open, unit, narrowed, total, resource.name]);
95
+
96
+ const chosen = UNITS.find((entry) => entry.key === unit)!;
97
+ const plural = resource.pluralLabel.toLowerCase();
98
+
99
+ return (
100
+ <section className="rounded-xl border bg-card">
101
+ <button type="button" onClick={toggle} aria-expanded={open} aria-controls={panel} className="flex w-full items-center gap-2 rounded-xl px-4 py-3 text-left text-sm font-medium hover:bg-muted/50">
102
+ <BarChart3Icon className="size-4 text-muted-foreground" />
103
+ Insights
104
+ <span className="truncate font-normal text-muted-foreground">{narrowed ? `for the ${plural} this list is showing` : `when ${plural} were created, and how they split`}</span>
105
+ {loading && <Spinner className="ml-1" />}
106
+ <ChevronDownIcon className={cn("ml-auto size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
107
+ </button>
108
+
109
+ {open && (
110
+ <div id={panel} className="border-t p-4">
111
+ {error && !data && (
112
+ <p role="alert" className="text-sm text-destructive">
113
+ {error}
114
+ </p>
115
+ )}
116
+ {!data && !error && <p className="text-sm text-muted-foreground">Counting…</p>}
117
+ {data && (
118
+ <div className={cn("grid gap-x-10 gap-y-8", data.breakdown.length > 0 && "lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]", loading && "opacity-60")}>
119
+ <div className="flex min-w-0 flex-col gap-3">
120
+ <div className="flex flex-wrap items-center justify-between gap-2">
121
+ <p className="text-sm">
122
+ <span className="font-medium">Created per {chosen.per}</span> <span className="text-muted-foreground">· {chosen.span}</span>
123
+ </p>
124
+ <div role="group" aria-label="Period" className="flex rounded-md border p-0.5 text-xs">
125
+ {UNITS.map((entry) => (
126
+ <button
127
+ key={entry.key}
128
+ type="button"
129
+ aria-pressed={entry.key === unit}
130
+ onClick={() => setUnit(entry.key)}
131
+ className={cn("rounded px-2 py-1", entry.key === unit ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
132
+ >
133
+ {entry.label}
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ <Series unit={data.unit} series={data.series} noun={plural} />
139
+ </div>
140
+
141
+ {data.breakdown.length > 0 && (
142
+ <div className="flex min-w-0 flex-col gap-6">
143
+ {data.breakdown.map((group) => (
144
+ <Breakdown key={group.field} resource={resource} group={group} />
145
+ ))}
146
+ </div>
147
+ )}
148
+ </div>
149
+ )}
150
+ </div>
151
+ )}
152
+ </section>
153
+ );
154
+ }
155
+
156
+ function Series({ unit, series, noun }: { unit: InsightsUnit; series: InsightsData["series"]; noun: string }) {
157
+ const most = Math.max(0, ...series.map((point) => point.count));
158
+ const sum = series.reduce((all, point) => all + point.count, 0);
159
+ const peak = series.find((point) => point.count === most);
160
+ const middle = series[Math.floor(series.length / 2)];
161
+ const summary =
162
+ sum === 0 || !peak
163
+ ? `No ${noun} were created in this time.`
164
+ : `${sum.toLocaleString()} created in this time. The most was ${most.toLocaleString()}, ${unit === "day" ? "on" : "in"} ${periodLabel(unit, peak.bucket, true)}.`;
165
+
166
+ return (
167
+ <figure className="flex flex-col gap-2">
168
+ <div role="img" aria-label={summary} className="flex h-40 items-end gap-0.5 border-b pb-px">
169
+ {series.map((point) => (
170
+ <div key={point.bucket} title={`${point.count.toLocaleString()} · ${periodLabel(unit, point.bucket, true)}`} className="group flex h-full min-w-0 flex-1 items-end">
171
+ <div
172
+ // A count of one in a tall chart still has to be seen: never thinner than a hairline.
173
+ style={{ height: most > 0 && point.count > 0 ? `max(${(point.count / most) * 100}%, 3px)` : "1px" }}
174
+ className={cn("w-full rounded-t-sm", point.count > 0 ? "bg-primary/75 group-hover:bg-primary" : "bg-border")}
175
+ />
176
+ </div>
177
+ ))}
178
+ </div>
179
+ <div className="flex justify-between text-xs text-muted-foreground tabular-nums" aria-hidden="true">
180
+ <span>{series[0] && periodLabel(unit, series[0].bucket)}</span>
181
+ <span>{middle && periodLabel(unit, middle.bucket)}</span>
182
+ <span>{series.at(-1) && periodLabel(unit, series.at(-1)!.bucket)}</span>
183
+ </div>
184
+ <figcaption className="text-xs text-muted-foreground">{summary}</figcaption>
185
+ </figure>
186
+ );
187
+ }
188
+
189
+ function Breakdown({ resource, group }: { resource: ClientResource; group: InsightsData["breakdown"][number] }) {
190
+ const def = resource.fields[group.field];
191
+ const sum = group.slices.reduce((all, slice) => all + slice.count, 0);
192
+ const name = (value: string) => {
193
+ if (value === "") return "Not set";
194
+ if (group.kind === "boolean") return value === "true" ? "Yes" : "No";
195
+ return def && def.kind === "enum" ? optionLabel(def, value) : value;
196
+ };
197
+
198
+ return (
199
+ <div className="flex flex-col gap-2">
200
+ <p className="text-sm font-medium">{def && "label" in def && def.label ? String(def.label) : group.field}</p>
201
+ <ul className="flex flex-col gap-1.5">
202
+ {group.slices.map((slice) => {
203
+ const share = sum > 0 ? (slice.count / sum) * 100 : 0;
204
+ return (
205
+ <li key={slice.value} className="grid grid-cols-[minmax(4rem,7rem)_1fr_auto] items-center gap-3 text-sm">
206
+ <span className="truncate text-muted-foreground">{name(slice.value)}</span>
207
+ <span className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
208
+ <span className="block h-full rounded-full bg-primary/75" style={{ width: `${share}%` }} />
209
+ </span>
210
+ <span className="tabular-nums">
211
+ {slice.count.toLocaleString()} <span className="text-muted-foreground">· {Math.round(share)}%</span>
212
+ </span>
213
+ </li>
214
+ );
215
+ })}
216
+ </ul>
217
+ </div>
218
+ );
219
+ }