@tekki-forge/runtime-api 0.2.0 → 0.3.0

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/dist/dashboard.js CHANGED
@@ -2,6 +2,8 @@ import { authorize, evaluate } from "@tekki-forge/runtime-permissions";
2
2
  import { QueryError, parseListQuery, parseSort } from "./query.js";
3
3
  import { relationInclude } from "./serialize.js";
4
4
  const MAX_BUCKET_ROWS = 50_000;
5
+ const MAX_STACKS = 8;
6
+ const TIMESTAMPS = ["createdAt", "updatedAt"];
5
7
  /** "$today", "$today-30d", "$now", "$startOfMonth", "$me" → concrete values. */
6
8
  export function resolveTokens(where, userId, now = new Date()) {
7
9
  const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
@@ -28,7 +30,32 @@ export function resolveTokens(where, userId, now = new Date()) {
28
30
  }
29
31
  return out;
30
32
  }
31
- export function bucketKey(d, by) {
33
+ // ---- Timezones ----
34
+ const PARTS = new Map();
35
+ /** `d` as wall-clock time in `timeZone`, expressed as a Date whose UTC fields hold the local values. */
36
+ export function zonedWallClock(d, timeZone) {
37
+ if (timeZone === "UTC")
38
+ return d;
39
+ let f = PARTS.get(timeZone);
40
+ if (!f) {
41
+ f = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", hourCycle: "h23" });
42
+ PARTS.set(timeZone, f);
43
+ }
44
+ const p = Object.fromEntries(f.formatToParts(d).map((x) => [x.type, x.value]));
45
+ return new Date(Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second), d.getUTCMilliseconds()));
46
+ }
47
+ /** The instant at which the wall clock in `timeZone` shows midnight of `date` (YYYY-MM-DD); DST-safe. */
48
+ export function zonedMidnight(date, timeZone) {
49
+ const guess = new Date(`${date}T00:00:00Z`).getTime();
50
+ if (timeZone === "UTC")
51
+ return new Date(guess);
52
+ const offset = (t) => zonedWallClock(new Date(t), timeZone).getTime() - t;
53
+ const first = guess - offset(guess);
54
+ const second = guess - offset(first);
55
+ return new Date(second);
56
+ }
57
+ export function bucketKey(d, by, timeZone = "UTC") {
58
+ d = zonedWallClock(d, timeZone);
32
59
  const y = d.getUTCFullYear();
33
60
  const m = String(d.getUTCMonth() + 1).padStart(2, "0");
34
61
  if (by === "year")
@@ -53,8 +80,35 @@ function nextBucket(key, by) {
53
80
  d.setUTCDate(d.getUTCDate() + (by === "week" ? 7 : 1));
54
81
  return d.toISOString().slice(0, 10);
55
82
  }
83
+ const bucketStart = (key) => (key.length === 4 ? `${key}-01-01` : key.length === 7 ? `${key}-01` : key);
84
+ /**
85
+ * List filters selecting one bucket: dates for date fields (and UTC dashboards), instants of local midnight otherwise,
86
+ * so clicking a point opens exactly the records it counts.
87
+ */
88
+ export function bucketRange(key, by, kind, timeZone = "UTC") {
89
+ const start = bucketStart(key);
90
+ const end = bucketStart(nextBucket(key, by));
91
+ if (kind === "date" || timeZone === "UTC")
92
+ return { gte: start, lt: end };
93
+ return { gte: zonedMidnight(start, timeZone).toISOString(), lt: zonedMidnight(end, timeZone).toISOString() };
94
+ }
56
95
  /** Periods in a metric's sparkline (the last one is the current period). */
57
96
  export const TREND_PERIODS = 12;
97
+ /** Start of the current calendar period (weeks start on Monday) in `timeZone`: the local date and its instant. */
98
+ export function periodStart(now, period, timeZone = "UTC") {
99
+ const l = zonedWallClock(now, timeZone);
100
+ const y = l.getUTCFullYear();
101
+ const m = l.getUTCMonth();
102
+ const start = period === "week"
103
+ ? new Date(Date.UTC(y, m, l.getUTCDate() - ((l.getUTCDay() + 6) % 7)))
104
+ : period === "month"
105
+ ? new Date(Date.UTC(y, m, 1))
106
+ : period === "quarter"
107
+ ? new Date(Date.UTC(y, m - (m % 3), 1))
108
+ : new Date(Date.UTC(y, 0, 1));
109
+ const date = start.toISOString().slice(0, 10);
110
+ return { date, instant: zonedMidnight(date, timeZone) };
111
+ }
58
112
  /** `d` moved by `n` periods; months clamp to the month's last day (31 Mar − 1 month = 28/29 Feb). */
59
113
  export function shiftPeriod(d, period, n) {
60
114
  if (period === "week")
@@ -77,21 +131,30 @@ export function trendWindows(now, period, count = TREND_PERIODS) {
77
131
  });
78
132
  }
79
133
  const toNumber = (v) => (v === null || v === undefined ? 0 : Number(typeof v === "object" ? String(v) : v));
134
+ /** Ratio of two aggregates, null without a denominator. */
135
+ export const ratioOf = (numerator, denominator) => (numerator === null || !denominator ? null : numerator / denominator);
80
136
  export class DashboardService {
81
137
  service;
82
138
  constructor(service) {
83
139
  this.service = service;
84
140
  }
85
- readable(entity, w, ctx) {
141
+ entityReadable(entity, fields, ctx) {
86
142
  if (!authorize(entity, "read", ctx.principal).allowed)
87
143
  return false;
88
144
  if (entity.features.tenant && !ctx.principal?.organizationId)
89
145
  return false;
90
- for (const name of [w.field, w.groupBy?.field, ...Object.keys(w.where).map((k) => k.split(".")[0])]) {
91
- if (!this.fieldReadable(entity, name, ctx))
92
- return false;
93
- }
94
- return true;
146
+ return fields.every((name) => this.fieldReadable(entity, name, ctx));
147
+ }
148
+ sourceReadable(s, ctx, extra = []) {
149
+ const entity = this.service.entityByName(s.entity);
150
+ return !!entity && this.entityReadable(entity, [s.field, s.groupField, ...Object.keys(s.where).map((k) => k.split(".")[0]), ...extra], ctx);
151
+ }
152
+ readable(entity, w, ctx) {
153
+ if (w.ratio)
154
+ return [w.ratio.numerator, w.ratio.denominator].every((s) => this.sourceReadable(s, ctx, w.period || w.compare ? [s.dateField] : []));
155
+ if (w.series)
156
+ return w.series.some((s) => this.sourceReadable(s, ctx));
157
+ return this.entityReadable(entity, [w.field, w.groupBy?.field, w.stackBy, w.period?.dateField, ...Object.keys(w.where).map((k) => k.split(".")[0])], ctx);
95
158
  }
96
159
  fieldReadable(entity, name, ctx) {
97
160
  const f = entity.fields.find((x) => x.name === name || x.apiName === name);
@@ -100,13 +163,29 @@ export class DashboardService {
100
163
  const d = evaluate(f.access.read, ctx.principal);
101
164
  return d.allowed && d.scope === "all";
102
165
  }
103
- /** `now` is injectable for tests; relative dates in filters ($today…) are resolved against it. */
104
- async compute(ctx, now = new Date()) {
166
+ /** Tabs the viewer may see (none when the dashboard has no tabs). */
167
+ visibleTabs(ctx) {
168
+ const tabs = this.service.app.dashboard?.tabs ?? [];
169
+ return tabs.filter((t) => !t.visibleTo || t.visibleTo.some((r) => ctx.principal?.roles.includes(r))).map((t) => ({ id: t.id, title: t.title }));
170
+ }
171
+ /**
172
+ * The widgets computed for the viewer. With tabs, `tab` picks one (default: every visible tab's widgets);
173
+ * `now` is injectable for tests; relative dates in filters ($today…) are resolved against it.
174
+ */
175
+ async compute(ctx, now = new Date(), tab) {
105
176
  const dashboard = this.service.app.dashboard;
106
177
  if (!dashboard)
107
178
  return { widgets: null };
179
+ const tabs = dashboard.tabs ? this.visibleTabs(ctx) : undefined;
180
+ let ids;
181
+ if (dashboard.tabs) {
182
+ const chosen = dashboard.tabs.filter((t) => tabs.some((v) => v.id === t.id) && (tab === undefined || t.id === tab));
183
+ ids = new Set(chosen.flatMap((t) => t.widgets));
184
+ }
108
185
  const out = [];
109
186
  for (const w of dashboard.widgets) {
187
+ if (ids && !ids.has(w.id))
188
+ continue;
110
189
  const entity = this.service.entityByName(w.entity);
111
190
  if (w.visibleTo && !w.visibleTo.some((r) => ctx.principal?.roles.includes(r)))
112
191
  continue;
@@ -114,20 +193,14 @@ export class DashboardService {
114
193
  continue;
115
194
  const base = { id: w.id, type: w.type, title: w.title, span: w.span, entity: entity.name, path: entity.path, ...(w.aggregate !== "count" && w.field ? { field: w.field } : {}) };
116
195
  try {
117
- const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
118
- const filters = parseListQuery(entity, resolveTokens(w.where, ctx.principal?.id ?? null, now), access).where;
119
- const where = { AND: [...this.service.readWhere(entity, ctx), ...filters] };
120
- if (w.type === "metric") {
121
- // The trend is dropped (not the widget) when the viewer can't read the date field it is based on.
122
- const withTrend = w.compare && this.fieldReadable(entity, w.compare.dateField, ctx);
123
- const [value, trend] = await Promise.all([this.aggregate(entity, where, w), withTrend ? this.trend(entity, w, ctx, now) : undefined]);
124
- out.push({ ...base, value, ...(trend ? { trend } : {}) });
125
- }
196
+ if (w.type === "metric")
197
+ out.push({ ...base, ...(await this.metric(entity, w, ctx, now)) });
126
198
  else if (w.type === "chart")
127
- out.push({ ...base, chart: w.chart, groupBy: w.groupBy, data: await this.series(entity, where, w) });
199
+ out.push({ ...base, chart: w.chart, groupBy: w.groupBy, ...(await this.chart(entity, w, ctx, now)) });
128
200
  else {
201
+ const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
129
202
  const rows = await this.service.delegate(entity).findMany({
130
- where,
203
+ where: this.where(entity, w.where, ctx, now),
131
204
  orderBy: parseSort(entity, w.sort, access),
132
205
  take: w.limit,
133
206
  include: relationInclude(entity),
@@ -142,16 +215,74 @@ export class DashboardService {
142
215
  out.push({ ...base, error: e instanceof QueryError ? e.message : "This widget could not be loaded" });
143
216
  }
144
217
  }
145
- return { widgets: out };
218
+ return { widgets: out, ...(tabs ? { tabs } : {}) };
219
+ }
220
+ get timeZone() {
221
+ return this.service.app.dashboard?.timezone ?? "UTC";
222
+ }
223
+ /** The viewer's row-level, tenant and owner rules plus the widget's filter. */
224
+ where(entity, where, ctx, now) {
225
+ const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
226
+ const filters = parseListQuery(entity, resolveTokens(where, ctx.principal?.id ?? null, now), access).where;
227
+ return { AND: [...this.service.readWhere(entity, ctx), ...filters] };
228
+ }
229
+ /**
230
+ * The whole current calendar period: `{ start of this period <= dateField < start of the next }`, so records
231
+ * dated later this month (upcoming bookings, due dates) count too. Date fields compare by local date,
232
+ * datetimes by instant.
233
+ */
234
+ periodFilter(entity, dateField, period, now) {
235
+ const start = periodStart(now, period, this.timeZone);
236
+ const nextDate = shiftPeriod(new Date(`${start.date}T00:00:00Z`), period, 1).toISOString().slice(0, 10);
237
+ // A date field holds a calendar date (stored at UTC midnight): compare it with the local dates.
238
+ if (this.fieldKind(entity, dateField) === "date")
239
+ return { [dateField]: { gte: new Date(`${start.date}T00:00:00Z`), lt: new Date(`${nextDate}T00:00:00Z`) } };
240
+ return { [dateField]: { gte: start.instant, lt: zonedMidnight(nextDate, this.timeZone) } };
241
+ }
242
+ async metric(entity, w, ctx, now) {
243
+ let value;
244
+ let parts = {};
245
+ if (w.ratio) {
246
+ const [n, d] = await Promise.all([w.ratio.numerator, w.ratio.denominator].map((s) => this.sourceValue(s, ctx, now, w.period ? { period: w.period.period } : undefined)));
247
+ value = ratioOf(n ?? null, d ?? null);
248
+ parts = { ratio: { numerator: n, denominator: d } };
249
+ }
250
+ else {
251
+ const where = this.where(entity, w.where, ctx, now);
252
+ value = await this.aggregate(entity, w.period ? { AND: [where, this.periodFilter(entity, w.period.dateField, w.period.period, now)] } : where, w.aggregate, w.field);
253
+ }
254
+ // The trend is dropped (not the widget) when the viewer can't read the date field it is based on.
255
+ const withTrend = w.compare && (w.ratio || this.fieldReadable(entity, w.compare.dateField, ctx));
256
+ const trend = withTrend ? await this.trend(entity, w, ctx, now) : undefined;
257
+ const numeric = value === null ? null : toNumber(value);
258
+ const goal = w.goal
259
+ ? {
260
+ target: w.goal.target,
261
+ progress: numeric === null ? 0 : numeric / w.goal.target,
262
+ reached: numeric !== null && numeric >= w.goal.target,
263
+ good: numeric === null ? null : (numeric >= w.goal.target) === (w.goal.trend === "up-is-good"),
264
+ }
265
+ : undefined;
266
+ return { value, ...parts, ...(trend ? { trend } : {}), ...(goal ? { goal } : {}), ...(w.period ? { period: w.period.period } : {}) };
267
+ }
268
+ /** One ratio side, within the viewer's rules, optionally limited to a calendar period or a [start, end) window. */
269
+ async sourceValue(s, ctx, now, window) {
270
+ const entity = this.service.entityByName(s.entity);
271
+ const asOf = window && "asOf" in window ? window.asOf : now;
272
+ const where = this.where(entity, s.where, ctx, asOf);
273
+ const dateField = s.dateField ?? "createdAt";
274
+ const extra = !window ? [] : "period" in window ? [this.periodFilter(entity, dateField, window.period, now)] : [{ [dateField]: { gte: window.start, lt: window.end } }];
275
+ const v = await this.aggregate(entity, { AND: [where, ...extra] }, s.aggregate, s.field);
276
+ return v === null ? null : toNumber(v);
146
277
  }
147
- async aggregate(entity, where, w) {
278
+ async aggregate(entity, where, aggregate, field) {
148
279
  const d = this.service.delegate(entity);
149
- if (w.aggregate === "count")
280
+ if (aggregate === "count")
150
281
  return d.count({ where });
151
- const op = `_${w.aggregate}`;
152
- const r = await d.aggregate({ where, [op]: { [w.field]: true } });
153
- const v = r[op]?.[w.field];
154
- return v === null || v === undefined ? (w.aggregate === "sum" ? 0 : null) : typeof v === "object" ? String(v) : v;
282
+ const op = `_${aggregate}`;
283
+ const r = await d.aggregate({ where, [op]: { [field]: true } });
284
+ const v = r[op]?.[field];
285
+ return v === null || v === undefined ? (aggregate === "sum" ? 0 : null) : typeof v === "object" ? String(v) : v;
155
286
  }
156
287
  /**
157
288
  * The metric over each of the last TREND_PERIODS rolling periods, within exactly the same row-level, tenant and
@@ -159,12 +290,13 @@ export class DashboardService {
159
290
  */
160
291
  async trend(entity, w, ctx, now) {
161
292
  const { period, dateField, trend } = w.compare;
162
- const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
163
- const scope = this.service.readWhere(entity, ctx);
164
293
  const windows = trendWindows(now, period);
165
- const values = await Promise.all(windows.map(async ({ start, end, asOf }) => {
166
- const filters = parseListQuery(entity, resolveTokens(w.where, ctx.principal?.id ?? null, asOf), access).where;
167
- const v = await this.aggregate(entity, { AND: [...scope, ...filters, { [dateField]: { gte: start, lt: end } }] }, w);
294
+ const values = await Promise.all(windows.map(async (win) => {
295
+ if (w.ratio) {
296
+ const [n, d] = await Promise.all([this.sourceValue(w.ratio.numerator, ctx, now, win), this.sourceValue(w.ratio.denominator, ctx, now, win)]);
297
+ return ratioOf(n, d);
298
+ }
299
+ const v = await this.aggregate(entity, { AND: [this.where(entity, w.where, ctx, win.asOf), { [dateField]: { gte: win.start, lt: win.end } }] }, w.aggregate, w.field);
168
300
  return v === null ? null : toNumber(v);
169
301
  }));
170
302
  const current = values.at(-1) ?? null;
@@ -182,48 +314,88 @@ export class DashboardService {
182
314
  series: windows.map((x, i) => ({ start: x.start.toISOString().slice(0, 10), end: x.end.toISOString().slice(0, 10), value: values[i] ?? null })),
183
315
  };
184
316
  }
185
- /** `label` is ready to show (default language); `key` is the raw group value so clients can localize it ("__other" = the rest). */
186
- async series(entity, where, w) {
187
- const g = w.groupBy;
188
- const d = this.service.delegate(entity);
189
- const f = entity.fields.find((x) => x.name === g.field);
190
- if (g.by) {
191
- // Date buckets computed from the rows (keeps every permission rule in the Prisma where).
192
- const rows = await d.findMany({ where: { AND: [where, { [g.field]: { not: null } }] }, select: { [g.field]: true, ...(w.field ? { [w.field]: true } : {}) }, orderBy: { [g.field]: "asc" }, take: MAX_BUCKET_ROWS });
193
- const buckets = new Map();
194
- for (const r of rows) {
195
- const key = bucketKey(new Date(r[g.field]), g.by);
196
- buckets.set(key, [...(buckets.get(key) ?? []), w.field ? toNumber(r[w.field]) : 1]);
197
- }
198
- const keys = [...buckets.keys()];
199
- if (!keys.length)
200
- return [];
201
- const out = [];
202
- for (let k = keys[0];; k = nextBucket(k, g.by)) {
203
- const values = buckets.get(k) ?? [];
204
- out.push({ label: k, key: k, value: reduce(values, w.aggregate) });
205
- if (k >= keys[keys.length - 1] || out.length > 1000)
206
- break;
207
- }
208
- return out;
209
- }
210
- const column = f?.relation ? f.relation.foreignKey : g.field;
211
- const grouped = await d.groupBy({
212
- by: [column],
213
- where,
214
- _count: { _all: true },
215
- ...(w.aggregate !== "count" ? { [`_${w.aggregate}`]: { [w.field]: true } } : {}),
317
+ // ---- Charts ----
318
+ async chart(entity, w, ctx, now) {
319
+ if (w.series)
320
+ return this.multiSeries(w, ctx, now);
321
+ const where = this.where(entity, w.where, ctx, now);
322
+ const filter = resolveTokens(w.where, ctx.principal?.id ?? null, now);
323
+ if (w.chart === "heatmap")
324
+ return { heatmap: await this.heatmap(entity, where, w, filter, now) };
325
+ if (w.chart === "funnel")
326
+ return { data: await this.funnel(entity, where, w, filter) };
327
+ if (w.stackBy)
328
+ return this.stacked(entity, where, w, filter);
329
+ const data = w.groupBy.by
330
+ ? await this.dateSeries(entity, where, { field: w.groupBy.field, by: w.groupBy.by, aggregate: w.aggregate, value: w.field, filter })
331
+ : await this.categories(entity, where, w.groupBy.field, w.aggregate, w.field, filter);
332
+ return { data: w.groupBy.by ? data : limitOther(data, w.limit) };
333
+ }
334
+ href(entity, filter, params) {
335
+ return `/${entity.path}?${new URLSearchParams({ ...filter, ...params }).toString()}`;
336
+ }
337
+ /** Contiguous date buckets (from the first to the last record), computed from the rows so every permission rule stays in the Prisma where. */
338
+ async dateBuckets(entity, where, field, by, value, extra = []) {
339
+ const rows = await this.service.delegate(entity).findMany({
340
+ where: this.notNull(entity, where, field),
341
+ select: { [field]: true, ...(value ? { [value]: true } : {}), ...Object.fromEntries(extra.map((x) => [x, true])) },
342
+ orderBy: { [field]: "asc" },
343
+ take: MAX_BUCKET_ROWS,
216
344
  });
217
- const value = (r) => (w.aggregate === "count" ? r._count._all : toNumber(r[`_${w.aggregate}`]?.[w.field]));
345
+ const tz = this.fieldTimeZone(entity, field);
346
+ const buckets = new Map();
347
+ for (const r of rows) {
348
+ const key = bucketKey(new Date(r[field]), by, tz);
349
+ buckets.set(key, [...(buckets.get(key) ?? []), r]);
350
+ }
351
+ const found = [...buckets.keys()].sort();
352
+ if (!found.length)
353
+ return { keys: [], buckets };
354
+ const keys = [];
355
+ for (let k = found[0];; k = nextBucket(k, by)) {
356
+ keys.push(k);
357
+ if (k >= found[found.length - 1] || keys.length > 1000)
358
+ break;
359
+ }
360
+ return { keys, buckets };
361
+ }
362
+ /** `where` and the date present (createdAt and required fields always are: Prisma rejects `not: null` on them). */
363
+ notNull(entity, where, field) {
364
+ const f = entity.fields.find((x) => x.name === field);
365
+ return !f || f.required ? where : { AND: [where, { [field]: { not: null } }] };
366
+ }
367
+ /** Date-only fields hold a calendar date: they are bucketed as stored, whatever the dashboard's timezone. */
368
+ fieldTimeZone(entity, field) {
369
+ return entity.fields.find((f) => f.name === field)?.kind === "date" ? "UTC" : this.timeZone;
370
+ }
371
+ fieldKind(entity, field) {
372
+ return TIMESTAMPS.includes(field) ? "datetime" : (entity.fields.find((f) => f.name === field)?.kind ?? "datetime");
373
+ }
374
+ rangeParams(entity, field, key, by) {
375
+ const range = bucketRange(key, by, this.fieldKind(entity, field), this.fieldTimeZone(entity, field));
376
+ return { [`${field}.gte`]: range.gte, [`${field}.lt`]: range.lt };
377
+ }
378
+ async dateSeries(entity, where, o) {
379
+ const { keys, buckets } = await this.dateBuckets(entity, where, o.field, o.by, o.value);
380
+ return keys.map((k) => ({
381
+ label: k,
382
+ key: k,
383
+ value: reduce((buckets.get(k) ?? []).map((r) => (o.value ? toNumber(r[o.value]) : 1)), o.aggregate),
384
+ href: this.href(entity, o.filter, this.rangeParams(entity, o.field, k, o.by)),
385
+ }));
386
+ }
387
+ /** Labels of category values in the default language (enum labels, yes/no, related records' display field). */
388
+ async labeler(entity, fieldName, keys) {
389
+ const f = entity.fields.find((x) => x.name === fieldName);
218
390
  let labels = new Map();
219
391
  if (f?.relation) {
220
- const ids = grouped.map((r) => r[column]).filter(Boolean);
392
+ const ids = keys.filter(Boolean);
221
393
  const target = f.relation.entity === "User" ? this.service.prisma.user : this.service.prisma[f.relation.delegate];
222
394
  const display = f.relation.targetDisplay;
223
395
  const rows = ids.length ? await target.findMany({ where: { id: { in: ids } }, select: { id: true, [display]: true } }) : [];
224
396
  labels = new Map(rows.map((r) => [r.id, String(r[display] ?? r.id)]));
225
397
  }
226
- const label = (v) => {
398
+ return (v) => {
227
399
  if (v === null || v === undefined)
228
400
  return "None";
229
401
  if (f?.enumValues)
@@ -232,16 +404,262 @@ export class DashboardService {
232
404
  return v ? "Yes" : "No";
233
405
  return labels.get(String(v)) ?? String(v);
234
406
  };
235
- const data = grouped
236
- .map((r) => ({ label: label(r[column]), key: r[column] === null || r[column] === undefined ? null : String(r[column]), value: value(r), href: `/${entity.path}?${encodeURIComponent(g.field)}=${r[column] === null ? "null" : encodeURIComponent(String(r[column]))}` }))
407
+ }
408
+ column(entity, fieldName) {
409
+ const f = entity.fields.find((x) => x.name === fieldName);
410
+ return f?.relation ? f.relation.foreignKey : fieldName;
411
+ }
412
+ async groupRows(entity, where, columns, aggregate, value) {
413
+ const rows = await this.service.delegate(entity).groupBy({
414
+ by: columns,
415
+ where,
416
+ _count: { _all: true },
417
+ ...(aggregate !== "count" ? { [`_${aggregate}`]: { [value]: true } } : {}),
418
+ });
419
+ return { rows, value: (r) => (aggregate === "count" ? r._count._all : toNumber(r[`_${aggregate}`]?.[value])) };
420
+ }
421
+ /** One point per category value, largest first. */
422
+ async categories(entity, where, fieldName, aggregate, value, filter) {
423
+ const column = this.column(entity, fieldName);
424
+ const { rows, value: of } = await this.groupRows(entity, where, [column], aggregate, value);
425
+ const label = await this.labeler(entity, fieldName, rows.map((r) => r[column]));
426
+ return rows
427
+ .map((r) => ({ label: label(r[column]), key: keyOf(r[column]), value: of(r), href: this.href(entity, filter, { [fieldName]: r[column] === null || r[column] === undefined ? "null" : String(r[column]) }) }))
237
428
  .sort((a, b) => b.value - a.value);
238
- if (data.length <= w.limit)
239
- return data;
240
- const rest = data.slice(w.limit - 1);
241
- return [...data.slice(0, w.limit - 1), { label: "Other", key: "__other", value: rest.reduce((s, x) => s + x.value, 0) }];
242
429
  }
430
+ /** Enum stages in order; cumulative = records at a stage or any later one. */
431
+ async funnel(entity, where, w, filter) {
432
+ const field = w.groupBy.field;
433
+ const stages = w.stages ?? [];
434
+ const { rows, value } = await this.groupRows(entity, { AND: [where, { [field]: { in: stages } }] }, [field], w.aggregate, w.field);
435
+ const label = await this.labeler(entity, field, []);
436
+ const at = new Map(rows.map((r) => [String(r[field]), value(r)]));
437
+ return stages.map((s, i) => {
438
+ const included = w.cumulative ? stages.slice(i) : [s];
439
+ return { label: label(s), key: s, value: included.reduce((sum, x) => sum + (at.get(x) ?? 0), 0), href: this.href(entity, filter, { [field]: included.join(",") }) };
440
+ });
441
+ }
442
+ /** groupBy × stackBy: categories plus one series per stack value (the smallest beyond MAX_STACKS merged as "Other"). */
443
+ async stacked(entity, where, w, filter) {
444
+ const g = w.groupBy;
445
+ const stackField = w.stackBy;
446
+ const stackColumn = this.column(entity, stackField);
447
+ // cells: category key → stack key → value
448
+ const cells = new Map();
449
+ const put = (c, s, v) => {
450
+ const row = cells.get(c) ?? new Map();
451
+ row.set(s, (row.get(s) ?? 0) + v);
452
+ cells.set(c, row);
453
+ };
454
+ let categories;
455
+ const NULL = "\u0000null";
456
+ const sk = (v) => (v === null || v === undefined ? NULL : String(v));
457
+ const stackRaw = new Map();
458
+ if (g.by) {
459
+ const by = g.by;
460
+ const { keys, buckets } = await this.dateBuckets(entity, where, g.field, by, w.field, [stackColumn]);
461
+ // Per bucket and stack value, the same reduction as a single series.
462
+ for (const k of keys) {
463
+ const byStack = new Map();
464
+ for (const r of buckets.get(k) ?? []) {
465
+ stackRaw.set(sk(r[stackColumn]), r[stackColumn]);
466
+ byStack.set(sk(r[stackColumn]), [...(byStack.get(sk(r[stackColumn])) ?? []), w.field ? toNumber(r[w.field]) : 1]);
467
+ }
468
+ for (const [s, vals] of byStack)
469
+ put(k, s, reduce(vals, w.aggregate));
470
+ }
471
+ categories = keys.map((k) => ({ label: k, key: k, value: 0, href: this.href(entity, filter, this.rangeParams(entity, g.field, k, by)) }));
472
+ }
473
+ else {
474
+ const column = this.column(entity, g.field);
475
+ const { rows, value } = await this.groupRows(entity, where, [column, stackColumn], w.aggregate, w.field);
476
+ for (const r of rows) {
477
+ stackRaw.set(sk(r[stackColumn]), r[stackColumn]);
478
+ put(sk(r[column]), sk(r[stackColumn]), value(r));
479
+ }
480
+ const label = await this.labeler(entity, g.field, rows.map((r) => r[column]));
481
+ const raw = new Map(rows.map((r) => [sk(r[column]), r[column]]));
482
+ categories = [...cells.keys()].map((c) => ({ label: label(raw.get(c)), key: c === NULL ? null : c, value: 0, href: this.href(entity, filter, { [g.field]: c === NULL ? "null" : c }) }));
483
+ }
484
+ const catKey = (c) => (c.key === null ? NULL : c.key);
485
+ for (const c of categories)
486
+ c.value = [...(cells.get(catKey(c))?.values() ?? [])].reduce((a, b) => a + b, 0);
487
+ if (!g.by)
488
+ categories.sort((a, b) => b.value - a.value);
489
+ // Categories beyond the limit are merged into "Other" (category axes only; dates stay contiguous).
490
+ if (!g.by && categories.length > w.limit) {
491
+ const rest = categories.slice(w.limit - 1);
492
+ const other = new Map();
493
+ for (const c of rest)
494
+ for (const [s, v] of cells.get(catKey(c)) ?? [])
495
+ other.set(s, (other.get(s) ?? 0) + v);
496
+ cells.set("__other", other);
497
+ categories = [...categories.slice(0, w.limit - 1), { label: "Other", key: "__other", value: rest.reduce((s, x) => s + x.value, 0) }];
498
+ }
499
+ // Stack values, largest first; the smallest are merged.
500
+ const totals = new Map();
501
+ for (const row of cells.values())
502
+ for (const [s, v] of row)
503
+ totals.set(s, (totals.get(s) ?? 0) + v);
504
+ const enumOrder = entity.fields.find((f) => f.name === stackField)?.enumValues?.map((v) => v.value);
505
+ let stacks = [...totals.keys()].sort((a, b) => (enumOrder ? enumOrder.indexOf(a) - enumOrder.indexOf(b) : (totals.get(b) ?? 0) - (totals.get(a) ?? 0)));
506
+ let otherStacks = [];
507
+ if (stacks.length > MAX_STACKS) {
508
+ const bySize = [...stacks].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
509
+ otherStacks = bySize.slice(MAX_STACKS - 1);
510
+ stacks = stacks.filter((s) => !otherStacks.includes(s));
511
+ }
512
+ const label = await this.labeler(entity, stackField, stacks.map((s) => stackRaw.get(s)));
513
+ const series = [...stacks, ...(otherStacks.length ? ["__other_stack"] : [])].map((s) => {
514
+ const keys = s === "__other_stack" ? otherStacks : [s];
515
+ return {
516
+ key: s === "__other_stack" ? "__other" : s === NULL ? null : s,
517
+ label: s === "__other_stack" ? "Other" : label(stackRaw.get(s)),
518
+ entity: entity.name,
519
+ path: entity.path,
520
+ aggregate: w.aggregate,
521
+ ...(w.field ? { field: w.field } : {}),
522
+ axis: "left",
523
+ values: categories.map((c) => keys.reduce((sum, k) => sum + (cells.get(catKey(c))?.get(k) ?? 0), 0)),
524
+ hrefs: categories.map((c) => {
525
+ if (!c.href || s === "__other_stack")
526
+ return null;
527
+ return `${c.href}&${new URLSearchParams({ [stackField]: s === NULL ? "null" : s }).toString()}`;
528
+ }),
529
+ };
530
+ });
531
+ return { data: categories, series, stackBy: stackField };
532
+ }
533
+ /**
534
+ * Several series (possibly from several entities) on one axis. Each series is computed within its entity's
535
+ * rules; a series the viewer can't read is dropped (not the chart).
536
+ */
537
+ async multiSeries(w, ctx, now) {
538
+ const visible = w.series.filter((s) => this.sourceReadable(s, ctx));
539
+ const by = w.groupBy?.by;
540
+ const computed = await Promise.all(visible.map(async (s) => {
541
+ const entity = this.service.entityByName(s.entity);
542
+ const where = this.where(entity, s.where, ctx, now);
543
+ const filter = resolveTokens(s.where, ctx.principal?.id ?? null, now);
544
+ const points = by
545
+ ? await this.dateSeries(entity, where, { field: s.groupField, by, aggregate: s.aggregate, value: s.field, filter })
546
+ : await this.categories(entity, where, s.groupField, s.aggregate, s.field, filter);
547
+ return { s, entity, points };
548
+ }));
549
+ // The shared axis: contiguous dates spanning every series, or the union of category values.
550
+ let keys;
551
+ const labels = new Map();
552
+ for (const c of computed)
553
+ for (const p of c.points)
554
+ if (!labels.has(p.key))
555
+ labels.set(p.key, p.label);
556
+ if (by) {
557
+ const all = [...labels.keys()].filter((k) => k !== null).sort();
558
+ keys = [];
559
+ if (all.length)
560
+ for (let k = all[0];; k = nextBucket(k, by)) {
561
+ keys.push(k);
562
+ if (k >= all[all.length - 1] || keys.length > 1000)
563
+ break;
564
+ }
565
+ }
566
+ else {
567
+ const total = new Map();
568
+ for (const c of computed)
569
+ for (const p of c.points)
570
+ total.set(p.key, (total.get(p.key) ?? 0) + p.value);
571
+ const first = computed[0] && computed[0].entity.fields.find((f) => f.name === computed[0].s.groupField)?.enumValues?.map((v) => v.value);
572
+ keys = [...total.keys()].sort((a, b) => (first ? first.indexOf(a ?? "") - first.indexOf(b ?? "") : (total.get(b) ?? 0) - (total.get(a) ?? 0)));
573
+ if (keys.length > w.limit)
574
+ keys = keys.slice(0, w.limit);
575
+ }
576
+ const series = computed.map(({ s, entity, points }) => {
577
+ const at = new Map(points.map((p) => [p.key, p]));
578
+ return {
579
+ key: s.id,
580
+ label: s.label,
581
+ entity: entity.name,
582
+ path: entity.path,
583
+ aggregate: s.aggregate,
584
+ ...(s.field ? { field: s.field } : {}),
585
+ axis: s.axis,
586
+ values: keys.map((k) => at.get(k)?.value ?? 0),
587
+ hrefs: keys.map((k) => {
588
+ if (at.get(k)?.href)
589
+ return at.get(k).href;
590
+ // Empty buckets still link to the (empty) filtered list.
591
+ return by && k ? this.href(entity, resolveTokens(s.where, ctx.principal?.id ?? null, now), this.rangeParams(entity, s.groupField, k, by)) : null;
592
+ }),
593
+ };
594
+ });
595
+ const data = keys.map((k, i) => ({ label: labels.get(k) ?? String(k), key: k, value: series.reduce((sum, x) => sum + (x.values[i] ?? 0), 0) }));
596
+ return { data, series };
597
+ }
598
+ /**
599
+ * Heatmap cells. `by: "hour"`: day of week (rows, Monday first) × hour of day (columns), in the dashboard's
600
+ * timezone. `by: "day"`: one cell per day, a column per week (Monday), up to the last 53 weeks.
601
+ */
602
+ async heatmap(entity, where, w, filter, now) {
603
+ const field = w.groupBy.field;
604
+ const tz = this.fieldTimeZone(entity, field);
605
+ const rows = await this.service.delegate(entity).findMany({
606
+ where: this.notNull(entity, where, field),
607
+ select: { [field]: true, ...(w.field ? { [w.field]: true } : {}) },
608
+ orderBy: { [field]: "desc" },
609
+ take: MAX_BUCKET_ROWS,
610
+ });
611
+ const val = (r) => (w.field ? toNumber(r[w.field]) : 1);
612
+ if (w.groupBy.by === "hour") {
613
+ const grid = new Map();
614
+ for (const r of rows) {
615
+ const local = zonedWallClock(new Date(r[field]), tz);
616
+ const key = `${(local.getUTCDay() + 6) % 7}:${local.getUTCHours()}`;
617
+ grid.set(key, [...(grid.get(key) ?? []), val(r)]);
618
+ }
619
+ const cells = [];
620
+ for (let row = 0; row < 7; row++)
621
+ for (let h = 0; h < 24; h++)
622
+ cells.push({ row, column: String(h), value: reduce(grid.get(`${row}:${h}`) ?? [], w.aggregate) });
623
+ return { by: "hour", timezone: tz, columns: Array.from({ length: 24 }, (_, h) => String(h)), cells };
624
+ }
625
+ const days = new Map();
626
+ for (const r of rows) {
627
+ const key = bucketKey(new Date(r[field]), "day", tz);
628
+ days.set(key, [...(days.get(key) ?? []), val(r)]);
629
+ }
630
+ const today = bucketKey(now, "day", tz);
631
+ const sorted = [...days.keys()].sort();
632
+ const last = sorted.length && sorted[sorted.length - 1] > today ? sorted[sorted.length - 1] : today;
633
+ const lastWeek = bucketKey(new Date(`${last}T00:00:00Z`), "week");
634
+ const earliest = new Date(`${lastWeek}T00:00:00Z`);
635
+ earliest.setUTCDate(earliest.getUTCDate() - 52 * 7);
636
+ const firstWeek = sorted.length ? [bucketKey(new Date(`${sorted[0]}T00:00:00Z`), "week"), earliest.toISOString().slice(0, 10)].sort()[1] : lastWeek;
637
+ const columns = [];
638
+ for (let k = firstWeek; k <= lastWeek; k = nextBucket(k, "week"))
639
+ columns.push(k);
640
+ const cells = [];
641
+ for (const week of columns) {
642
+ for (let row = 0; row < 7; row++) {
643
+ const d = new Date(`${week}T00:00:00Z`);
644
+ d.setUTCDate(d.getUTCDate() + row);
645
+ const date = d.toISOString().slice(0, 10);
646
+ if (date > last)
647
+ continue;
648
+ cells.push({ row, column: week, date, value: reduce(days.get(date) ?? [], w.aggregate), href: this.href(entity, filter, this.rangeParams(entity, field, date, "day")) });
649
+ }
650
+ }
651
+ return { by: "day", timezone: tz, columns, cells };
652
+ }
653
+ }
654
+ const keyOf = (v) => (v === null || v === undefined ? null : String(v));
655
+ /** The first `limit − 1` points and the rest summed as "Other". */
656
+ function limitOther(data, limit) {
657
+ if (data.length <= limit)
658
+ return data;
659
+ const rest = data.slice(limit - 1);
660
+ return [...data.slice(0, limit - 1), { label: "Other", key: "__other", value: rest.reduce((s, x) => s + x.value, 0) }];
243
661
  }
244
- function reduce(values, aggregate) {
662
+ export function reduce(values, aggregate) {
245
663
  if (!values.length)
246
664
  return 0;
247
665
  switch (aggregate) {