@tekki-forge/runtime-api 0.1.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/admin.d.ts +146 -0
- package/dist/admin.js +256 -0
- package/dist/admin.js.map +1 -0
- package/dist/bootstrap.js +17 -2
- package/dist/bootstrap.js.map +1 -1
- package/dist/controllers.d.ts +273 -4
- package/dist/controllers.js +157 -7
- package/dist/controllers.js.map +1 -1
- package/dist/dashboard.d.ts +102 -5
- package/dist/dashboard.js +493 -73
- package/dist/dashboard.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/seed.js +10 -0
- package/dist/seed.js.map +1 -1
- package/dist/service.d.ts +3 -0
- package/dist/service.js +15 -2
- package/dist/service.js.map +1 -1
- package/dist/settings.d.ts +88 -0
- package/dist/settings.js +252 -0
- package/dist/settings.js.map +1 -0
- package/dist/site-controller.d.ts +115 -0
- package/dist/site-controller.js +373 -0
- package/dist/site-controller.js.map +1 -0
- package/dist/site.d.ts +410 -0
- package/dist/site.js +1413 -0
- package/dist/site.js.map +1 -0
- package/dist/support.d.ts +175 -0
- package/dist/support.js +251 -0
- package/dist/support.js.map +1 -0
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
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,32 +163,44 @@ export class DashboardService {
|
|
|
100
163
|
const d = evaluate(f.access.read, ctx.principal);
|
|
101
164
|
return d.allowed && d.scope === "all";
|
|
102
165
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
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);
|
|
190
|
+
if (w.visibleTo && !w.visibleTo.some((r) => ctx.principal?.roles.includes(r)))
|
|
191
|
+
continue;
|
|
111
192
|
if (!this.readable(entity, w, ctx))
|
|
112
193
|
continue;
|
|
113
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 } : {}) };
|
|
114
195
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const where = { AND: [...this.service.readWhere(entity, ctx), ...filters] };
|
|
118
|
-
if (w.type === "metric") {
|
|
119
|
-
// The trend is dropped (not the widget) when the viewer can't read the date field it is based on.
|
|
120
|
-
const withTrend = w.compare && this.fieldReadable(entity, w.compare.dateField, ctx);
|
|
121
|
-
const [value, trend] = await Promise.all([this.aggregate(entity, where, w), withTrend ? this.trend(entity, w, ctx, now) : undefined]);
|
|
122
|
-
out.push({ ...base, value, ...(trend ? { trend } : {}) });
|
|
123
|
-
}
|
|
196
|
+
if (w.type === "metric")
|
|
197
|
+
out.push({ ...base, ...(await this.metric(entity, w, ctx, now)) });
|
|
124
198
|
else if (w.type === "chart")
|
|
125
|
-
out.push({ ...base, chart: w.chart, groupBy: w.groupBy,
|
|
199
|
+
out.push({ ...base, chart: w.chart, groupBy: w.groupBy, ...(await this.chart(entity, w, ctx, now)) });
|
|
126
200
|
else {
|
|
201
|
+
const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
|
|
127
202
|
const rows = await this.service.delegate(entity).findMany({
|
|
128
|
-
where,
|
|
203
|
+
where: this.where(entity, w.where, ctx, now),
|
|
129
204
|
orderBy: parseSort(entity, w.sort, access),
|
|
130
205
|
take: w.limit,
|
|
131
206
|
include: relationInclude(entity),
|
|
@@ -140,16 +215,74 @@ export class DashboardService {
|
|
|
140
215
|
out.push({ ...base, error: e instanceof QueryError ? e.message : "This widget could not be loaded" });
|
|
141
216
|
}
|
|
142
217
|
}
|
|
143
|
-
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 } : {}) };
|
|
144
267
|
}
|
|
145
|
-
|
|
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);
|
|
277
|
+
}
|
|
278
|
+
async aggregate(entity, where, aggregate, field) {
|
|
146
279
|
const d = this.service.delegate(entity);
|
|
147
|
-
if (
|
|
280
|
+
if (aggregate === "count")
|
|
148
281
|
return d.count({ where });
|
|
149
|
-
const op = `_${
|
|
150
|
-
const r = await d.aggregate({ where, [op]: { [
|
|
151
|
-
const v = r[op]?.[
|
|
152
|
-
return v === null || v === undefined ? (
|
|
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;
|
|
153
286
|
}
|
|
154
287
|
/**
|
|
155
288
|
* The metric over each of the last TREND_PERIODS rolling periods, within exactly the same row-level, tenant and
|
|
@@ -157,12 +290,13 @@ export class DashboardService {
|
|
|
157
290
|
*/
|
|
158
291
|
async trend(entity, w, ctx, now) {
|
|
159
292
|
const { period, dateField, trend } = w.compare;
|
|
160
|
-
const access = { ...this.service.queryAccess(entity, ctx), anyField: true };
|
|
161
|
-
const scope = this.service.readWhere(entity, ctx);
|
|
162
293
|
const windows = trendWindows(now, period);
|
|
163
|
-
const values = await Promise.all(windows.map(async (
|
|
164
|
-
|
|
165
|
-
|
|
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);
|
|
166
300
|
return v === null ? null : toNumber(v);
|
|
167
301
|
}));
|
|
168
302
|
const current = values.at(-1) ?? null;
|
|
@@ -180,48 +314,88 @@ export class DashboardService {
|
|
|
180
314
|
series: windows.map((x, i) => ({ start: x.start.toISOString().slice(0, 10), end: x.end.toISOString().slice(0, 10), value: values[i] ?? null })),
|
|
181
315
|
};
|
|
182
316
|
}
|
|
183
|
-
|
|
184
|
-
async
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
by: [column],
|
|
211
|
-
where,
|
|
212
|
-
_count: { _all: true },
|
|
213
|
-
...(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,
|
|
214
344
|
});
|
|
215
|
-
const
|
|
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);
|
|
216
390
|
let labels = new Map();
|
|
217
391
|
if (f?.relation) {
|
|
218
|
-
const ids =
|
|
392
|
+
const ids = keys.filter(Boolean);
|
|
219
393
|
const target = f.relation.entity === "User" ? this.service.prisma.user : this.service.prisma[f.relation.delegate];
|
|
220
394
|
const display = f.relation.targetDisplay;
|
|
221
395
|
const rows = ids.length ? await target.findMany({ where: { id: { in: ids } }, select: { id: true, [display]: true } }) : [];
|
|
222
396
|
labels = new Map(rows.map((r) => [r.id, String(r[display] ?? r.id)]));
|
|
223
397
|
}
|
|
224
|
-
|
|
398
|
+
return (v) => {
|
|
225
399
|
if (v === null || v === undefined)
|
|
226
400
|
return "None";
|
|
227
401
|
if (f?.enumValues)
|
|
@@ -230,16 +404,262 @@ export class DashboardService {
|
|
|
230
404
|
return v ? "Yes" : "No";
|
|
231
405
|
return labels.get(String(v)) ?? String(v);
|
|
232
406
|
};
|
|
233
|
-
|
|
234
|
-
|
|
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]) }) }))
|
|
235
428
|
.sort((a, b) => b.value - a.value);
|
|
236
|
-
if (data.length <= w.limit)
|
|
237
|
-
return data;
|
|
238
|
-
const rest = data.slice(w.limit - 1);
|
|
239
|
-
return [...data.slice(0, w.limit - 1), { label: "Other", key: "__other", value: rest.reduce((s, x) => s + x.value, 0) }];
|
|
240
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) }];
|
|
241
661
|
}
|
|
242
|
-
function reduce(values, aggregate) {
|
|
662
|
+
export function reduce(values, aggregate) {
|
|
243
663
|
if (!values.length)
|
|
244
664
|
return 0;
|
|
245
665
|
switch (aggregate) {
|