@aglyn/plugins-forms 1.0.0-beta.199 → 1.0.0-beta.201
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/package.json +13 -12
- package/src/lib/components/form-detail-card.js +24 -6
- package/src/lib/components/form-detail-card.js.map +1 -1
- package/src/lib/components/form-list-query.d.ts +53 -0
- package/src/lib/components/form-list-query.js +242 -0
- package/src/lib/components/form-list-query.js.map +1 -0
- package/src/lib/components/form-metrics-card.component.d.ts +5 -4
- package/src/lib/components/form-metrics-card.component.js +7 -6
- package/src/lib/components/form-metrics-card.component.js.map +1 -1
- package/src/lib/components/host-forms-card.component.d.ts +7 -11
- package/src/lib/components/host-forms-card.component.js +187 -213
- package/src/lib/components/host-forms-card.component.js.map +1 -1
- package/src/lib/components/use-form-stats-recount-api.d.ts +38 -0
- package/src/lib/components/use-form-stats-recount-api.js +49 -0
- package/src/lib/components/use-form-stats-recount-api.js.map +1 -0
- package/src/lib/declarations.server.d.ts +41 -0
- package/src/lib/declarations.server.js +89 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/server/form-stats.d.ts +85 -0
- package/src/lib/server/form-stats.js +111 -0
- package/src/lib/server/form-stats.js.map +1 -0
- package/src/lib/server.d.ts +42 -0
- package/src/lib/server.js +122 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
/*==========================================
|
|
18
|
+
* WHAT THE FORMS LIST FILTERS AND SEARCHES BY — ALL OF IT ON THE QUERY
|
|
19
|
+
* (AGL-3330, on the AGL-3321 list query plan).
|
|
20
|
+
*
|
|
21
|
+
* The list pages a site's catalog (`hosts/{hostId}/forms`), so a filter
|
|
22
|
+
* matched over the rows already read would answer "no such form" for one on
|
|
23
|
+
* a later page. Every clause and the search word go on ONE Firestore query
|
|
24
|
+
* (`planListQuery`), paged by that query (`useListQuery`); what one query
|
|
25
|
+
* cannot hold is refused by name, never matched in memory.
|
|
26
|
+
*
|
|
27
|
+
* Each field is one every form stores: the create paths write it
|
|
28
|
+
* (`newFormListFields`), a rename rewrites the search keys
|
|
29
|
+
* (`formListFields`), and `tools/scripts/backfill-form-list-fields.mjs`
|
|
30
|
+
* stamps the forms made before.
|
|
31
|
+
*
|
|
32
|
+
* - Display name: a word of it (`nameTokens`), the shared name-search
|
|
33
|
+
* prefixes every `displayName`-named document carries.
|
|
34
|
+
* - Slug: stored normalized (`normalizeFormSlug`), so it is its own key.
|
|
35
|
+
* - Submissions: the counter `/api/forms/submit` increments on the form, a
|
|
36
|
+
* number or a range. Leads: the same counter's equality — a count, or
|
|
37
|
+
* none. A form that counted nothing holds `null`, never zero, so "is
|
|
38
|
+
* empty" is an equality.
|
|
39
|
+
* - Last submission (epoch ms) and Updated: days, before or after.
|
|
40
|
+
* - Status: `retired`, the queryable mirror of `archivedAt`.
|
|
41
|
+
* - Lead routing: `routing.lead`, a boolean on every form.
|
|
42
|
+
* - Campaign: `campaignIds`, any of the picked campaigns, as stored.
|
|
43
|
+
* - In a campaign: `inCampaign`, the boolean every writer of `campaignIds`
|
|
44
|
+
* stamps beside it (`formCampaignFields`), because "in no campaign" is an
|
|
45
|
+
* empty array, which no query can ask for.
|
|
46
|
+
*
|
|
47
|
+
* ## The index budget, and what it bought
|
|
48
|
+
*
|
|
49
|
+
* One query holds any number of equalities, one array clause and one range,
|
|
50
|
+
* and a range leads the order. Unfiltered, and under equalities alone, the
|
|
51
|
+
* list reads in document order, which Firestore serves by merging its
|
|
52
|
+
* single-field indexes: no composite. A composite is needed only where an
|
|
53
|
+
* equality or array field meets a RANGE, one per (field, range order) pair
|
|
54
|
+
* (`listQueryIndexes`), so the count is (equality fields) × (range orders).
|
|
55
|
+
*
|
|
56
|
+
* Three ranges — Submissions, Last submission, Updated — are the visible
|
|
57
|
+
* columns with no other honest operator. Leads is asked by equality (a
|
|
58
|
+
* count, or none — a lead-routing form holds `0` until its first lead, so
|
|
59
|
+
* "= 0" and "is empty" are different questions), Display name by a word,
|
|
60
|
+
* Slug exactly, so none of those adds an order; a Leads range would be a
|
|
61
|
+
* fourth order and more composites. Nine equality fields (the search tokens,
|
|
62
|
+
* the name tokens, Slug, the two counters' equalities, `retired`, Lead
|
|
63
|
+
* routing, Campaign, In a campaign) times three orders, less the Submissions
|
|
64
|
+
* equality against its own order, is 26 — In a campaign is the last three,
|
|
65
|
+
* the whole of what "in no campaign" costs. `form-list-query.spec.ts` pins
|
|
66
|
+
* every one against the index file.
|
|
67
|
+
*=========================================*/ /** Every filter the forms grid offers, in the `ListFilterField` grammar. */ export const FORM_LIST_FILTER_FIELDS = [
|
|
68
|
+
{
|
|
69
|
+
column: 'displayName',
|
|
70
|
+
kind: 'text',
|
|
71
|
+
path: 'displayName',
|
|
72
|
+
tokensPath: 'nameTokens',
|
|
73
|
+
operators: [
|
|
74
|
+
'contains'
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
column: 'slug',
|
|
79
|
+
kind: 'text',
|
|
80
|
+
path: 'slug',
|
|
81
|
+
lowerPath: 'slug',
|
|
82
|
+
operators: [
|
|
83
|
+
'equals'
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
column: 'submissions',
|
|
88
|
+
kind: 'number',
|
|
89
|
+
path: 'stats.submissions',
|
|
90
|
+
presence: 'nullable'
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
column: 'leads',
|
|
94
|
+
kind: 'number',
|
|
95
|
+
path: 'stats.leads',
|
|
96
|
+
presence: 'nullable',
|
|
97
|
+
operators: [
|
|
98
|
+
'=',
|
|
99
|
+
'isEmpty'
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
column: 'lastSubmission',
|
|
104
|
+
kind: 'date',
|
|
105
|
+
path: 'stats.lastSubmissionAtMs',
|
|
106
|
+
storedAs: 'millis',
|
|
107
|
+
operators: [
|
|
108
|
+
'is',
|
|
109
|
+
'after',
|
|
110
|
+
'onOrAfter',
|
|
111
|
+
'before',
|
|
112
|
+
'onOrBefore',
|
|
113
|
+
'isNotEmpty'
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
column: 'updatedAt',
|
|
118
|
+
kind: 'date',
|
|
119
|
+
path: 'updatedAt',
|
|
120
|
+
presence: 'always'
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
column: 'status',
|
|
124
|
+
kind: 'boolean',
|
|
125
|
+
path: 'retired',
|
|
126
|
+
operators: [
|
|
127
|
+
'equals'
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
column: 'leadRouting',
|
|
132
|
+
kind: 'boolean',
|
|
133
|
+
path: 'routing.lead',
|
|
134
|
+
operators: [
|
|
135
|
+
'equals'
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
column: 'campaignIds',
|
|
140
|
+
kind: 'exact',
|
|
141
|
+
path: 'campaignIds',
|
|
142
|
+
// The array itself: "any of" is one `array-contains-any`.
|
|
143
|
+
tokensPath: 'campaignIds',
|
|
144
|
+
operators: [
|
|
145
|
+
'isAnyOf'
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
column: 'inCampaign',
|
|
150
|
+
kind: 'boolean',
|
|
151
|
+
path: 'inCampaign',
|
|
152
|
+
operators: [
|
|
153
|
+
'equals'
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
];
|
|
157
|
+
/** The forms list's query: its fields, its one order, and its search. */ export const FORM_LIST_QUERY = {
|
|
158
|
+
fields: FORM_LIST_FILTER_FIELDS,
|
|
159
|
+
// Document order, as the list has always read, which needs no composite.
|
|
160
|
+
sorts: [
|
|
161
|
+
{
|
|
162
|
+
path: '__name__',
|
|
163
|
+
direction: 'asc'
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
search: {
|
|
167
|
+
tokensPath: 'searchTokens'
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
/** How each field reads on a chip and as a hidden column's header. */ export const FORM_LIST_FILTER_HEADERS = {
|
|
171
|
+
displayName: 'Display name',
|
|
172
|
+
slug: 'Slug',
|
|
173
|
+
submissions: 'Submissions',
|
|
174
|
+
leads: 'Leads',
|
|
175
|
+
lastSubmission: 'Last submission',
|
|
176
|
+
updatedAt: 'Updated',
|
|
177
|
+
status: 'Status',
|
|
178
|
+
leadRouting: 'Lead routing',
|
|
179
|
+
campaignIds: 'Campaign',
|
|
180
|
+
inCampaign: 'In a campaign'
|
|
181
|
+
};
|
|
182
|
+
/** Status is the stored `retired`, picked by name. */ export const FORM_STATUS_OPTIONS = [
|
|
183
|
+
{
|
|
184
|
+
value: 'false',
|
|
185
|
+
label: 'Active'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
value: 'true',
|
|
189
|
+
label: 'Retired'
|
|
190
|
+
}
|
|
191
|
+
];
|
|
192
|
+
/** Lead routing is the stored switch, picked by name. */ export const FORM_LEAD_ROUTING_OPTIONS = [
|
|
193
|
+
{
|
|
194
|
+
value: 'true',
|
|
195
|
+
label: 'On'
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
value: 'false',
|
|
199
|
+
label: 'Off'
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
/** Whether a form is in any campaign, picked by name. */ export const FORM_IN_CAMPAIGN_OPTIONS = [
|
|
203
|
+
{
|
|
204
|
+
value: 'true',
|
|
205
|
+
label: 'Yes'
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
value: 'false',
|
|
209
|
+
label: 'No'
|
|
210
|
+
}
|
|
211
|
+
];
|
|
212
|
+
/** The fields picked from choices rather than typed. */ export const FORM_LIST_SELECT_FIELDS = [
|
|
213
|
+
'status',
|
|
214
|
+
'leadRouting',
|
|
215
|
+
'campaignIds',
|
|
216
|
+
'inCampaign'
|
|
217
|
+
];
|
|
218
|
+
/**
|
|
219
|
+
* A retired form is a tombstone the list leaves out unless the reader asks
|
|
220
|
+
* for Status (AGL-2671). On the query, so a page is a page of forms in use
|
|
221
|
+
* rather than one with retired rows dropped from it.
|
|
222
|
+
*/ export const FORM_IN_USE = {
|
|
223
|
+
path: 'retired',
|
|
224
|
+
op: '==',
|
|
225
|
+
value: false
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* What the forms list asks this time: the clauses in force, the search
|
|
229
|
+
* words, and the forms in use unless Status is one of the clauses.
|
|
230
|
+
*/ export function formListRequest(clauses, search) {
|
|
231
|
+
const askedStatus = clauses.some((clause)=>clause.field === 'status');
|
|
232
|
+
return _extends({
|
|
233
|
+
clauses,
|
|
234
|
+
search
|
|
235
|
+
}, askedStatus ? {} : {
|
|
236
|
+
base: [
|
|
237
|
+
FORM_IN_USE
|
|
238
|
+
]
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
//# sourceMappingURL=form-list-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-list-query.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQueryRequest,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*==========================================\n * WHAT THE FORMS LIST FILTERS AND SEARCHES BY — ALL OF IT ON THE QUERY\n * (AGL-3330, on the AGL-3321 list query plan).\n *\n * The list pages a site's catalog (`hosts/{hostId}/forms`), so a filter\n * matched over the rows already read would answer \"no such form\" for one on\n * a later page. Every clause and the search word go on ONE Firestore query\n * (`planListQuery`), paged by that query (`useListQuery`); what one query\n * cannot hold is refused by name, never matched in memory.\n *\n * Each field is one every form stores: the create paths write it\n * (`newFormListFields`), a rename rewrites the search keys\n * (`formListFields`), and `tools/scripts/backfill-form-list-fields.mjs`\n * stamps the forms made before.\n *\n * - Display name: a word of it (`nameTokens`), the shared name-search\n * prefixes every `displayName`-named document carries.\n * - Slug: stored normalized (`normalizeFormSlug`), so it is its own key.\n * - Submissions: the counter `/api/forms/submit` increments on the form, a\n * number or a range. Leads: the same counter's equality — a count, or\n * none. A form that counted nothing holds `null`, never zero, so \"is\n * empty\" is an equality.\n * - Last submission (epoch ms) and Updated: days, before or after.\n * - Status: `retired`, the queryable mirror of `archivedAt`.\n * - Lead routing: `routing.lead`, a boolean on every form.\n * - Campaign: `campaignIds`, any of the picked campaigns, as stored.\n * - In a campaign: `inCampaign`, the boolean every writer of `campaignIds`\n * stamps beside it (`formCampaignFields`), because \"in no campaign\" is an\n * empty array, which no query can ask for.\n *\n * ## The index budget, and what it bought\n *\n * One query holds any number of equalities, one array clause and one range,\n * and a range leads the order. Unfiltered, and under equalities alone, the\n * list reads in document order, which Firestore serves by merging its\n * single-field indexes: no composite. A composite is needed only where an\n * equality or array field meets a RANGE, one per (field, range order) pair\n * (`listQueryIndexes`), so the count is (equality fields) × (range orders).\n *\n * Three ranges — Submissions, Last submission, Updated — are the visible\n * columns with no other honest operator. Leads is asked by equality (a\n * count, or none — a lead-routing form holds `0` until its first lead, so\n * \"= 0\" and \"is empty\" are different questions), Display name by a word,\n * Slug exactly, so none of those adds an order; a Leads range would be a\n * fourth order and more composites. Nine equality fields (the search tokens,\n * the name tokens, Slug, the two counters' equalities, `retired`, Lead\n * routing, Campaign, In a campaign) times three orders, less the Submissions\n * equality against its own order, is 26 — In a campaign is the last three,\n * the whole of what \"in no campaign\" costs. `form-list-query.spec.ts` pins\n * every one against the index file.\n *=========================================*/\n\n/** Every filter the forms grid offers, in the `ListFilterField` grammar. */\nexport const FORM_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'displayName',\n kind: 'text',\n path: 'displayName',\n tokensPath: 'nameTokens',\n operators: ['contains'],\n },\n {\n column: 'slug',\n kind: 'text',\n path: 'slug',\n lowerPath: 'slug',\n operators: ['equals'],\n },\n { column: 'submissions', kind: 'number', path: 'stats.submissions', presence: 'nullable' },\n {\n column: 'leads',\n kind: 'number',\n path: 'stats.leads',\n presence: 'nullable',\n operators: ['=', 'isEmpty'],\n },\n {\n column: 'lastSubmission',\n kind: 'date',\n path: 'stats.lastSubmissionAtMs',\n storedAs: 'millis',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore', 'isNotEmpty'],\n },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n { column: 'status', kind: 'boolean', path: 'retired', operators: ['equals'] },\n { column: 'leadRouting', kind: 'boolean', path: 'routing.lead', operators: ['equals'] },\n {\n column: 'campaignIds',\n kind: 'exact',\n path: 'campaignIds',\n // The array itself: \"any of\" is one `array-contains-any`.\n tokensPath: 'campaignIds',\n operators: ['isAnyOf'],\n },\n { column: 'inCampaign', kind: 'boolean', path: 'inCampaign', operators: ['equals'] },\n]\n\n/** The forms list's query: its fields, its one order, and its search. */\nexport const FORM_LIST_QUERY: ListQueryDeclaration = {\n fields: FORM_LIST_FILTER_FIELDS,\n // Document order, as the list has always read, which needs no composite.\n sorts: [{ path: '__name__', direction: 'asc' }],\n search: { tokensPath: 'searchTokens' },\n}\n\n/** How each field reads on a chip and as a hidden column's header. */\nexport const FORM_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n displayName: 'Display name',\n slug: 'Slug',\n submissions: 'Submissions',\n leads: 'Leads',\n lastSubmission: 'Last submission',\n updatedAt: 'Updated',\n status: 'Status',\n leadRouting: 'Lead routing',\n campaignIds: 'Campaign',\n inCampaign: 'In a campaign',\n}\n\n/** Status is the stored `retired`, picked by name. */\nexport const FORM_STATUS_OPTIONS = [\n { value: 'false', label: 'Active' },\n { value: 'true', label: 'Retired' },\n]\n\n/** Lead routing is the stored switch, picked by name. */\nexport const FORM_LEAD_ROUTING_OPTIONS = [\n { value: 'true', label: 'On' },\n { value: 'false', label: 'Off' },\n]\n\n/** Whether a form is in any campaign, picked by name. */\nexport const FORM_IN_CAMPAIGN_OPTIONS = [\n { value: 'true', label: 'Yes' },\n { value: 'false', label: 'No' },\n]\n\n/** The fields picked from choices rather than typed. */\nexport const FORM_LIST_SELECT_FIELDS = [\n 'status',\n 'leadRouting',\n 'campaignIds',\n 'inCampaign',\n] as const\n\n/**\n * A retired form is a tombstone the list leaves out unless the reader asks\n * for Status (AGL-2671). On the query, so a page is a page of forms in use\n * rather than one with retired rows dropped from it.\n */\nexport const FORM_IN_USE: ListQueryFilter = { path: 'retired', op: '==', value: false }\n\n/**\n * What the forms list asks this time: the clauses in force, the search\n * words, and the forms in use unless Status is one of the clauses.\n */\nexport function formListRequest(\n clauses: readonly ListFilterClause[],\n search: readonly string[],\n): ListQueryRequest {\n const askedStatus = clauses.some((clause) => clause.field === 'status')\n return {\n clauses,\n search,\n ...(askedStatus ? {} : { base: [FORM_IN_USE] }),\n }\n}\n"],"names":["FORM_LIST_FILTER_FIELDS","column","kind","path","tokensPath","operators","lowerPath","presence","storedAs","FORM_LIST_QUERY","fields","sorts","direction","search","FORM_LIST_FILTER_HEADERS","displayName","slug","submissions","leads","lastSubmission","updatedAt","status","leadRouting","campaignIds","inCampaign","FORM_STATUS_OPTIONS","value","label","FORM_LEAD_ROUTING_OPTIONS","FORM_IN_CAMPAIGN_OPTIONS","FORM_LIST_SELECT_FIELDS","FORM_IN_USE","op","formListRequest","clauses","askedStatus","some","clause","field","base"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAkD2C,GAE3C,0EAA0E,GAC1E,OAAO,MAAMA,0BAAsD;IACjE;QACEC,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNC,YAAY;QACZC,WAAW;YAAC;SAAW;IACzB;IACA;QACEJ,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNG,WAAW;QACXD,WAAW;YAAC;SAAS;IACvB;IACA;QAAEJ,QAAQ;QAAeC,MAAM;QAAUC,MAAM;QAAqBI,UAAU;IAAW;IACzF;QACEN,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNI,UAAU;QACVF,WAAW;YAAC;YAAK;SAAU;IAC7B;IACA;QACEJ,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNK,UAAU;QACVH,WAAW;YAAC;YAAM;YAAS;YAAa;YAAU;YAAc;SAAa;IAC/E;IACA;QAAEJ,QAAQ;QAAaC,MAAM;QAAQC,MAAM;QAAaI,UAAU;IAAS;IAC3E;QAAEN,QAAQ;QAAUC,MAAM;QAAWC,MAAM;QAAWE,WAAW;YAAC;SAAS;IAAC;IAC5E;QAAEJ,QAAQ;QAAeC,MAAM;QAAWC,MAAM;QAAgBE,WAAW;YAAC;SAAS;IAAC;IACtF;QACEJ,QAAQ;QACRC,MAAM;QACNC,MAAM;QACN,0DAA0D;QAC1DC,YAAY;QACZC,WAAW;YAAC;SAAU;IACxB;IACA;QAAEJ,QAAQ;QAAcC,MAAM;QAAWC,MAAM;QAAcE,WAAW;YAAC;SAAS;IAAC;CACpF,CAAA;AAED,uEAAuE,GACvE,OAAO,MAAMI,kBAAwC;IACnDC,QAAQV;IACR,yEAAyE;IACzEW,OAAO;QAAC;YAAER,MAAM;YAAYS,WAAW;QAAM;KAAE;IAC/CC,QAAQ;QAAET,YAAY;IAAe;AACvC,EAAC;AAED,oEAAoE,GACpE,OAAO,MAAMU,2BAA6D;IACxEC,aAAa;IACbC,MAAM;IACNC,aAAa;IACbC,OAAO;IACPC,gBAAgB;IAChBC,WAAW;IACXC,QAAQ;IACRC,aAAa;IACbC,aAAa;IACbC,YAAY;AACd,EAAC;AAED,oDAAoD,GACpD,OAAO,MAAMC,sBAAsB;IACjC;QAAEC,OAAO;QAASC,OAAO;IAAS;IAClC;QAAED,OAAO;QAAQC,OAAO;IAAU;CACnC,CAAA;AAED,uDAAuD,GACvD,OAAO,MAAMC,4BAA4B;IACvC;QAAEF,OAAO;QAAQC,OAAO;IAAK;IAC7B;QAAED,OAAO;QAASC,OAAO;IAAM;CAChC,CAAA;AAED,uDAAuD,GACvD,OAAO,MAAME,2BAA2B;IACtC;QAAEH,OAAO;QAAQC,OAAO;IAAM;IAC9B;QAAED,OAAO;QAASC,OAAO;IAAK;CAC/B,CAAA;AAED,sDAAsD,GACtD,OAAO,MAAMG,0BAA0B;IACrC;IACA;IACA;IACA;CACD,CAAS;AAEV;;;;CAIC,GACD,OAAO,MAAMC,cAA+B;IAAE5B,MAAM;IAAW6B,IAAI;IAAMN,OAAO;AAAM,EAAC;AAEvF;;;CAGC,GACD,OAAO,SAASO,gBACdC,OAAoC,EACpCrB,MAAyB;IAEzB,MAAMsB,cAAcD,QAAQE,IAAI,CAAC,CAACC,SAAWA,OAAOC,KAAK,KAAK;IAC9D,OAAO;QACLJ;QACArB;OACIsB,cAAc,CAAC,IAAI;QAAEI,MAAM;YAACR;SAAY;IAAC;AAEjD"}
|
|
@@ -21,10 +21,11 @@ export interface FormMetricsCardProps {
|
|
|
21
21
|
* created repeatedly.
|
|
22
22
|
*
|
|
23
23
|
* - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single
|
|
24
|
-
* `update` it already issues per submission
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* `update` it already issues per submission, and recounted from their rows
|
|
25
|
+
* after a removal (AGL-3330). `leads` counts the PEOPLE this form filed to
|
|
26
|
+
* the Leads list that are still there: `addHostLead` keys one person to one
|
|
27
|
+
* document, so a returning visitor's second submission is a second
|
|
28
|
+
* submission and not a second lead.
|
|
28
29
|
* - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the
|
|
29
30
|
* form's own beacon, in the same shape the overlay counters use.
|
|
30
31
|
* - `stats.periods` — all four again, keyed by month, riding the same
|
|
@@ -41,10 +41,11 @@ import { useMemo } from "react";
|
|
|
41
41
|
* created repeatedly.
|
|
42
42
|
*
|
|
43
43
|
* - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single
|
|
44
|
-
* `update` it already issues per submission
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* `update` it already issues per submission, and recounted from their rows
|
|
45
|
+
* after a removal (AGL-3330). `leads` counts the PEOPLE this form filed to
|
|
46
|
+
* the Leads list that are still there: `addHostLead` keys one person to one
|
|
47
|
+
* document, so a returning visitor's second submission is a second
|
|
48
|
+
* submission and not a second lead.
|
|
48
49
|
* - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the
|
|
49
50
|
* form's own beacon, in the same shape the overlay counters use.
|
|
50
51
|
* - `stats.periods` — all four again, keyed by month, riding the same
|
|
@@ -153,9 +154,9 @@ import { useMemo } from "react";
|
|
|
153
154
|
note: "since this form was created"
|
|
154
155
|
}),
|
|
155
156
|
/*#__PURE__*/ _jsx(Figure, {
|
|
156
|
-
label: "
|
|
157
|
+
label: "Leads",
|
|
157
158
|
value: leads,
|
|
158
|
-
note: "
|
|
159
|
+
note: "people this form filed to Leads"
|
|
159
160
|
}),
|
|
160
161
|
/*#__PURE__*/ _jsx(Figure, {
|
|
161
162
|
label: "Declared fields",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-metrics-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { FormFieldDecl, FormStats } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n measuredRate,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Divider, Stack, Tooltip, Typography } from '@mui/material'\nimport { formPeriodSeries, formStatsWindow, pluginDocsHelp } from '@aglyn/aglyn'\nimport { useMemo } from 'react'\n\nexport interface FormMetricsCardProps {\n /** The stored counters, or `undefined` while the document is in flight. */\n stats?: FormStats\n /** The PUBLISHED declaration — what a submission to this form can carry. */\n fields: FormFieldDecl[]\n /** Whether this form routes a submission to a lead. */\n leadRouting: boolean\n /** The form document has not arrived yet, as against having no counters. */\n loading?: boolean\n}\n\n/**\n * A number, or `null` when the field carries no number at all.\n *\n * `?? 0` is the defect this exists to avoid: a counter that has never been\n * written and a counter standing at zero are different facts, and only one of\n * them is a measurement. Everything on this card goes through here.\n */\nfunction recorded(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * ONE FORM, MEASURED — and every rate naming its denominator.\n *\n * ## Where every number here comes from\n *\n * Counters on the form document, each incremented on a write that was\n * happening anyway. The alternative — counting `formSubmissions` when a\n * console surface renders — reads the one collection that grows without bound\n * and the one the customer is billed on, and it is the shape this product has\n * created repeatedly.\n *\n * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single\n * `update` it already issues per submission. `leads` counts submissions\n * this form FILED to the site's Leads; `addHostLead` keys one person to\n * one document, so a returning visitor's second submission is a second\n * capture and not a second person.\n * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the\n * form's own beacon, in the same shape the overlay counters use.\n * - `stats.periods` — all four again, keyed by month, riding the same\n * writes. This is the series drawn below AND the reason the rates can be\n * taken at all (see next).\n *\n * ## Why the rates are not lifetime over lifetime\n *\n * `submissions` has counted since the form entity existed; `views` only since\n * the beacon shipped. Dividing the lifetime totals would answer \"submissions\n * ever, over views since the day we started looking\" — arbitrarily wrong, and\n * wrong in the flattering direction. `formStatsWindow` sums both counters\n * over exactly the months the denominator was live for, and answers zero\n * periods when there are none, which renders as a dash.\n *\n * ## The view count is a CLIENT count, and this card says so\n *\n * A view is a beacon; a submission is a server write. A blocked beacon, a\n * browser that runs no script, a visitor who leaves before the request goes\n * out — each is a view that is missing from the denominator and a submission\n * that is present in the numerator. So a completion rate here can exceed\n * 100%, and it is printed as measured rather than clamped: a rate pinned at a\n * tidy 100% would read as a form everybody finishes.\n *\n * ## What is still NOT measured, and why\n *\n * An absence with a reason is a smaller lie than a number with none.\n *\n * - **Per-field answer rates.** The values live on the submission documents\n * in the Inbox, and counting them is the unbounded read every counter here\n * exists to avoid.\n * - **Anything before a counter started.** The series begins at the first\n * month with a record rather than padding backwards with zeros, and the\n * rates cover only the months their denominator was live.\n */\nexport function FormMetricsCard(props: FormMetricsCardProps) {\n const { stats, fields, leadRouting, loading } = props\n const submissions = recorded(stats?.submissions)\n const leads = recorded(stats?.leads)\n const views = recorded(stats?.views)\n const lastAt = recorded(stats?.lastSubmissionAtMs)\n\n const series = useMemo(() => formPeriodSeries(stats), [stats])\n /** The tallest month, which every bar below is drawn as a fraction of. */\n const peak = useMemo(\n () => series.reduce((high, point) => Math.max(high, point.submissions), 0),\n [series],\n )\n\n /*\n * `measuredRate` returns `null` on an unrecorded numerator OR a zero\n * denominator, which is both halves of the rule at once: a form nobody has\n * submitted to has no rate, and a form whose leads were never counted has\n * no rate either. Neither is `0%`.\n *\n * The denominator is named `submissions` and not \"people\" on purpose. A\n * lead is filed only from a submission that carried an address, so a rate\n * over every submission and a rate over the addressed ones are different\n * numbers — and this is the one the counter actually produces.\n */\n const leadRate = measuredRate(leads, submissions, 'submissions')\n /*\n * COMPLETION AND ABANDONMENT, each over the months its own denominator was\n * recorded in — never over the lifetime totals, and never over each\n * other's window. Views and starts began being counted on the same deploy\n * but they do not stay in step: a form nobody typed into has views and no\n * starts, so a shared window would silently take the abandonment rate over\n * months that recorded no start at all.\n */\n const viewWindow = formStatsWindow(stats, 'views', 'submissions')\n const startWindow = formStatsWindow(stats, 'starts', 'submissions')\n const completionRate = viewWindow.periods\n ? measuredRate(viewWindow.of, viewWindow.over, 'views')\n : null\n /*\n * Abandonment is the starts that did NOT arrive, and it is withheld — not\n * clamped — when there are more submissions than starts.\n *\n * That happens for a real reason rather than as an arithmetic edge: the\n * start is a beacon and the submission is a server write, so a visitor\n * whose beacon was blocked submits without ever having started. Clamping\n * the difference at zero would publish \"nobody abandons this form\" out of a\n * measurement that had gone incoherent, which is the loudest possible\n * version of the mistake this whole card is written against.\n */\n const abandonRate =\n startWindow.periods && startWindow.over >= startWindow.of\n ? measuredRate(\n startWindow.over - startWindow.of,\n startWindow.over,\n 'starts',\n )\n : null\n\n return (\n <CardDisplay\n header=\"What this form has collected\"\n help={pluginDocsHelp('forms', {\n anchor: '#one-forms-own-page',\n excerpt:\n 'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, and each rate covers only the months ' +\n 'its denominator was recorded.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure\n label=\"Views\"\n value={loading ? null : views}\n note=\"times this form was rendered\"\n />\n <Figure\n label=\"Submissions\"\n value={loading ? null : submissions}\n note=\"since this form was created\"\n />\n <Figure\n label=\"Lead captures\"\n value={leads}\n note=\"submissions filed to Leads\"\n />\n <Figure\n label=\"Declared fields\"\n value={fields.length}\n note=\"on the published design\"\n />\n </Stack>\n <Divider />\n <Section title=\"Rates\">\n <RateRow label=\"Views that became a submission\" rate={completionRate} />\n <RateRow label=\"Started and never submitted\" rate={abandonRate} />\n <RateRow label=\"Submissions that became a lead\" rate={leadRate} />\n {!leadRouting ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This form does not route to leads, so there is nothing for ' +\n 'that rate to be over.'}\n </Typography>\n ) : null}\n {/*\n The measurement's own shape, on the surface rather than in a\n docblock only a maintainer reads. Both facts change how the\n numbers above should be read: which months they cover, and that\n one side of them is counted in a browser.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, so a blocked request is a view ' +\n 'this misses and a submission it does not. Each rate covers ' +\n 'only the months its denominator was being recorded.'}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Submissions by month\">\n {series.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing recorded yet. The series starts at the first month ' +\n 'this form collects something.'}\n </Typography>\n ) : (\n /*\n A bar per month, drawn from the counters themselves.\n Deliberately starting at the first RECORDED month rather than a\n rolling twelve: a month before the counter existed would draw as\n a confident zero, which is a claim nobody measured.\n */\n <Stack spacing={0.5}>\n {series.map((point) => (\n <Stack\n key={point.period}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ minWidth: 64 }}\n >\n {point.period}\n </Typography>\n <Tooltip\n title={`${point.submissions.toLocaleString()} submissions${\n point.views\n ? `, ${point.views.toLocaleString()} views`\n : ''\n }`}\n >\n <Stack\n sx={{\n height: 8,\n borderRadius: 1,\n bgcolor: 'primary.main',\n // Zero keeps a hairline rather than vanishing: a row\n // with no bar at all reads as a missing month, and a\n // missing month is the one thing this series must\n // never be confused with.\n minWidth: 2,\n width: peak\n ? `${Math.round((point.submissions / peak) * 100)}%`\n : 2,\n }}\n />\n </Tooltip>\n <Typography variant=\"caption\" sx={{ minWidth: 40 }}>\n {point.submissions.toLocaleString()}\n </Typography>\n </Stack>\n ))}\n </Stack>\n )}\n </Section>\n <Divider />\n <Section title=\"Most recent\">\n <Typography variant=\"body2\">\n {lastAt === null\n ? 'No submission has been recorded for this form.'\n : new Date(lastAt).toLocaleString()}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Not measured\">\n {/*\n Named rather than omitted. A report that quietly leaves out the\n question a reader came with is indistinguishable from one that\n answered it badly.\n */}\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }} spacing={0.5}>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Per-field answer rates — which questions people skip. The ' +\n 'values live on the submission documents, and counting them ' +\n 'is a read of the whole collection every time this page ' +\n 'opens.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Anything before a counter started. The month series begins ' +\n 'where the record begins rather than padding backwards with ' +\n 'zeros, and a rate is taken only over the months its ' +\n 'denominator was live.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Who abandoned, or where they stopped. A start is one signal ' +\n 'per form, not per field, so this says how often a form was ' +\n 'left and never which question lost the visitor.'}\n </Typography>\n </Stack>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nFormMetricsCard.displayName = 'FormMetricsCard'\n\nexport default FormMetricsCard\n"],"names":["CardDisplay","Figure","measuredRate","RateRow","Section","Divider","Stack","Tooltip","Typography","formPeriodSeries","formStatsWindow","pluginDocsHelp","useMemo","recorded","value","Number","isFinite","FormMetricsCard","props","stats","fields","leadRouting","loading","submissions","leads","views","lastAt","lastSubmissionAtMs","series","peak","reduce","high","point","Math","max","leadRate","viewWindow","startWindow","completionRate","periods","of","over","abandonRate","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","rowGap","label","note","length","title","rate","variant","color","map","alignItems","minWidth","period","toLocaleString","height","borderRadius","bgcolor","width","round","Date","component","pl","m","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-metrics-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { FormFieldDecl, FormStats } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n measuredRate,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Divider, Stack, Tooltip, Typography } from '@mui/material'\nimport { formPeriodSeries, formStatsWindow, pluginDocsHelp } from '@aglyn/aglyn'\nimport { useMemo } from 'react'\n\nexport interface FormMetricsCardProps {\n /** The stored counters, or `undefined` while the document is in flight. */\n stats?: FormStats\n /** The PUBLISHED declaration — what a submission to this form can carry. */\n fields: FormFieldDecl[]\n /** Whether this form routes a submission to a lead. */\n leadRouting: boolean\n /** The form document has not arrived yet, as against having no counters. */\n loading?: boolean\n}\n\n/**\n * A number, or `null` when the field carries no number at all.\n *\n * `?? 0` is the defect this exists to avoid: a counter that has never been\n * written and a counter standing at zero are different facts, and only one of\n * them is a measurement. Everything on this card goes through here.\n */\nfunction recorded(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * ONE FORM, MEASURED — and every rate naming its denominator.\n *\n * ## Where every number here comes from\n *\n * Counters on the form document, each incremented on a write that was\n * happening anyway. The alternative — counting `formSubmissions` when a\n * console surface renders — reads the one collection that grows without bound\n * and the one the customer is billed on, and it is the shape this product has\n * created repeatedly.\n *\n * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single\n * `update` it already issues per submission, and recounted from their rows\n * after a removal (AGL-3330). `leads` counts the PEOPLE this form filed to\n * the Leads list that are still there: `addHostLead` keys one person to one\n * document, so a returning visitor's second submission is a second\n * submission and not a second lead.\n * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the\n * form's own beacon, in the same shape the overlay counters use.\n * - `stats.periods` — all four again, keyed by month, riding the same\n * writes. This is the series drawn below AND the reason the rates can be\n * taken at all (see next).\n *\n * ## Why the rates are not lifetime over lifetime\n *\n * `submissions` has counted since the form entity existed; `views` only since\n * the beacon shipped. Dividing the lifetime totals would answer \"submissions\n * ever, over views since the day we started looking\" — arbitrarily wrong, and\n * wrong in the flattering direction. `formStatsWindow` sums both counters\n * over exactly the months the denominator was live for, and answers zero\n * periods when there are none, which renders as a dash.\n *\n * ## The view count is a CLIENT count, and this card says so\n *\n * A view is a beacon; a submission is a server write. A blocked beacon, a\n * browser that runs no script, a visitor who leaves before the request goes\n * out — each is a view that is missing from the denominator and a submission\n * that is present in the numerator. So a completion rate here can exceed\n * 100%, and it is printed as measured rather than clamped: a rate pinned at a\n * tidy 100% would read as a form everybody finishes.\n *\n * ## What is still NOT measured, and why\n *\n * An absence with a reason is a smaller lie than a number with none.\n *\n * - **Per-field answer rates.** The values live on the submission documents\n * in the Inbox, and counting them is the unbounded read every counter here\n * exists to avoid.\n * - **Anything before a counter started.** The series begins at the first\n * month with a record rather than padding backwards with zeros, and the\n * rates cover only the months their denominator was live.\n */\nexport function FormMetricsCard(props: FormMetricsCardProps) {\n const { stats, fields, leadRouting, loading } = props\n const submissions = recorded(stats?.submissions)\n const leads = recorded(stats?.leads)\n const views = recorded(stats?.views)\n const lastAt = recorded(stats?.lastSubmissionAtMs)\n\n const series = useMemo(() => formPeriodSeries(stats), [stats])\n /** The tallest month, which every bar below is drawn as a fraction of. */\n const peak = useMemo(\n () => series.reduce((high, point) => Math.max(high, point.submissions), 0),\n [series],\n )\n\n /*\n * `measuredRate` returns `null` on an unrecorded numerator OR a zero\n * denominator, which is both halves of the rule at once: a form nobody has\n * submitted to has no rate, and a form whose leads were never counted has\n * no rate either. Neither is `0%`.\n *\n * The denominator is named `submissions` and not \"people\" on purpose. A\n * lead is filed only from a submission that carried an address, so a rate\n * over every submission and a rate over the addressed ones are different\n * numbers — and this is the one the counter actually produces.\n */\n const leadRate = measuredRate(leads, submissions, 'submissions')\n /*\n * COMPLETION AND ABANDONMENT, each over the months its own denominator was\n * recorded in — never over the lifetime totals, and never over each\n * other's window. Views and starts began being counted on the same deploy\n * but they do not stay in step: a form nobody typed into has views and no\n * starts, so a shared window would silently take the abandonment rate over\n * months that recorded no start at all.\n */\n const viewWindow = formStatsWindow(stats, 'views', 'submissions')\n const startWindow = formStatsWindow(stats, 'starts', 'submissions')\n const completionRate = viewWindow.periods\n ? measuredRate(viewWindow.of, viewWindow.over, 'views')\n : null\n /*\n * Abandonment is the starts that did NOT arrive, and it is withheld — not\n * clamped — when there are more submissions than starts.\n *\n * That happens for a real reason rather than as an arithmetic edge: the\n * start is a beacon and the submission is a server write, so a visitor\n * whose beacon was blocked submits without ever having started. Clamping\n * the difference at zero would publish \"nobody abandons this form\" out of a\n * measurement that had gone incoherent, which is the loudest possible\n * version of the mistake this whole card is written against.\n */\n const abandonRate =\n startWindow.periods && startWindow.over >= startWindow.of\n ? measuredRate(\n startWindow.over - startWindow.of,\n startWindow.over,\n 'starts',\n )\n : null\n\n return (\n <CardDisplay\n header=\"What this form has collected\"\n help={pluginDocsHelp('forms', {\n anchor: '#one-forms-own-page',\n excerpt:\n 'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, and each rate covers only the months ' +\n 'its denominator was recorded.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure\n label=\"Views\"\n value={loading ? null : views}\n note=\"times this form was rendered\"\n />\n <Figure\n label=\"Submissions\"\n value={loading ? null : submissions}\n note=\"since this form was created\"\n />\n <Figure\n label=\"Leads\"\n value={leads}\n note=\"people this form filed to Leads\"\n />\n <Figure\n label=\"Declared fields\"\n value={fields.length}\n note=\"on the published design\"\n />\n </Stack>\n <Divider />\n <Section title=\"Rates\">\n <RateRow label=\"Views that became a submission\" rate={completionRate} />\n <RateRow label=\"Started and never submitted\" rate={abandonRate} />\n <RateRow label=\"Submissions that became a lead\" rate={leadRate} />\n {!leadRouting ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This form does not route to leads, so there is nothing for ' +\n 'that rate to be over.'}\n </Typography>\n ) : null}\n {/*\n The measurement's own shape, on the surface rather than in a\n docblock only a maintainer reads. Both facts change how the\n numbers above should be read: which months they cover, and that\n one side of them is counted in a browser.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, so a blocked request is a view ' +\n 'this misses and a submission it does not. Each rate covers ' +\n 'only the months its denominator was being recorded.'}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Submissions by month\">\n {series.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing recorded yet. The series starts at the first month ' +\n 'this form collects something.'}\n </Typography>\n ) : (\n /*\n A bar per month, drawn from the counters themselves.\n Deliberately starting at the first RECORDED month rather than a\n rolling twelve: a month before the counter existed would draw as\n a confident zero, which is a claim nobody measured.\n */\n <Stack spacing={0.5}>\n {series.map((point) => (\n <Stack\n key={point.period}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ minWidth: 64 }}\n >\n {point.period}\n </Typography>\n <Tooltip\n title={`${point.submissions.toLocaleString()} submissions${\n point.views\n ? `, ${point.views.toLocaleString()} views`\n : ''\n }`}\n >\n <Stack\n sx={{\n height: 8,\n borderRadius: 1,\n bgcolor: 'primary.main',\n // Zero keeps a hairline rather than vanishing: a row\n // with no bar at all reads as a missing month, and a\n // missing month is the one thing this series must\n // never be confused with.\n minWidth: 2,\n width: peak\n ? `${Math.round((point.submissions / peak) * 100)}%`\n : 2,\n }}\n />\n </Tooltip>\n <Typography variant=\"caption\" sx={{ minWidth: 40 }}>\n {point.submissions.toLocaleString()}\n </Typography>\n </Stack>\n ))}\n </Stack>\n )}\n </Section>\n <Divider />\n <Section title=\"Most recent\">\n <Typography variant=\"body2\">\n {lastAt === null\n ? 'No submission has been recorded for this form.'\n : new Date(lastAt).toLocaleString()}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Not measured\">\n {/*\n Named rather than omitted. A report that quietly leaves out the\n question a reader came with is indistinguishable from one that\n answered it badly.\n */}\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }} spacing={0.5}>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Per-field answer rates — which questions people skip. The ' +\n 'values live on the submission documents, and counting them ' +\n 'is a read of the whole collection every time this page ' +\n 'opens.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Anything before a counter started. The month series begins ' +\n 'where the record begins rather than padding backwards with ' +\n 'zeros, and a rate is taken only over the months its ' +\n 'denominator was live.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Who abandoned, or where they stopped. A start is one signal ' +\n 'per form, not per field, so this says how often a form was ' +\n 'left and never which question lost the visitor.'}\n </Typography>\n </Stack>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nFormMetricsCard.displayName = 'FormMetricsCard'\n\nexport default FormMetricsCard\n"],"names":["CardDisplay","Figure","measuredRate","RateRow","Section","Divider","Stack","Tooltip","Typography","formPeriodSeries","formStatsWindow","pluginDocsHelp","useMemo","recorded","value","Number","isFinite","FormMetricsCard","props","stats","fields","leadRouting","loading","submissions","leads","views","lastAt","lastSubmissionAtMs","series","peak","reduce","high","point","Math","max","leadRate","viewWindow","startWindow","completionRate","periods","of","over","abandonRate","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","rowGap","label","note","length","title","rate","variant","color","map","alignItems","minWidth","period","toLocaleString","height","borderRadius","bgcolor","width","round","Date","component","pl","m","displayName"],"mappings":"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"}
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import type { AglynOrgBilling } from '@aglyn/aglyn';
|
|
2
|
-
export declare const FORM_FILTER_FIELDS: import("@aglyn/shared-ui-jsx/src/lib/const/list-filter").ListFilterField[];
|
|
3
|
-
export declare const FORM_FILTER_HEADERS: Readonly<Record<string, string>>;
|
|
4
|
-
/** What the quick search reads: the name, the slug and the form's id. */
|
|
5
|
-
export declare const FORM_SEARCH_FIELDS: readonly ["displayName", "slug", "$id"];
|
|
6
|
-
/** A form row with the keys its hidden filters match on. */
|
|
7
|
-
export declare const formFilterRow: (form: any) => any;
|
|
8
2
|
export interface HostFormsCardProps {
|
|
9
3
|
hostId: string;
|
|
10
4
|
/**
|
|
@@ -49,11 +43,13 @@ export interface HostFormsCardProps {
|
|
|
49
43
|
* ## The two numeric columns, and when one is honestly blank
|
|
50
44
|
*
|
|
51
45
|
* `stats.submissions` and `stats.leads` are both incremented by
|
|
52
|
-
* `/api/forms/submit` on a write that was happening anyway,
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
46
|
+
* `/api/forms/submit` on a write that was happening anyway, and recounted
|
|
47
|
+
* from their rows after a removal (AGL-3330), so both are real numbers.
|
|
48
|
+
* `leads` is counted only for a form whose `routing.lead` is set — it holds
|
|
49
|
+
* `0` there until the first lead — so a form that has never routed to leads
|
|
50
|
+
* carries no figure at all and renders as a dash rather than as `0`. A zero
|
|
51
|
+
* would say this form has produced no leads, which for an unrouted form is a
|
|
52
|
+
* measurement nobody took.
|
|
57
53
|
*/
|
|
58
54
|
export declare function HostFormsCard(props: HostFormsCardProps): import("react").JSX.Element;
|
|
59
55
|
export declare namespace HostFormsCard {
|