@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.221

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.
Files changed (188) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +1 -59
  3. package/src/lib/app-utils/actions.js +7 -72
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  6. package/src/lib/app-utils/consent-groups.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +0 -36
  8. package/src/lib/app-utils/console-routes.js +2 -37
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contacts.d.ts +1 -1
  11. package/src/lib/app-utils/contacts.js.map +1 -1
  12. package/src/lib/app-utils/container-membership.d.ts +93 -0
  13. package/src/lib/app-utils/container-membership.js +165 -0
  14. package/src/lib/app-utils/container-membership.js.map +1 -0
  15. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  16. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  17. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  18. package/src/lib/app-utils/crm.d.ts +24 -4
  19. package/src/lib/app-utils/crm.js +23 -0
  20. package/src/lib/app-utils/crm.js.map +1 -1
  21. package/src/lib/app-utils/csv-upload.d.ts +1 -1
  22. package/src/lib/app-utils/csv-upload.js +1 -1
  23. package/src/lib/app-utils/csv-upload.js.map +1 -1
  24. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  25. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  26. package/src/lib/app-utils/csv.js.map +1 -0
  27. package/src/lib/app-utils/datasets.d.ts +0 -19
  28. package/src/lib/app-utils/datasets.js +0 -13
  29. package/src/lib/app-utils/datasets.js.map +1 -1
  30. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  31. package/src/lib/app-utils/docs-help.generated.js +5 -0
  32. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  33. package/src/lib/app-utils/docs-index.generated.js +71 -36
  34. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  35. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  36. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  37. package/src/lib/app-utils/email-text.d.ts +80 -0
  38. package/src/lib/app-utils/email-text.js +189 -0
  39. package/src/lib/app-utils/email-text.js.map +1 -0
  40. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  41. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  42. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  43. package/src/lib/app-utils/expand-repeatables.js +4 -4
  44. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  45. package/src/lib/app-utils/forms.js +2 -2
  46. package/src/lib/app-utils/forms.js.map +1 -1
  47. package/src/lib/app-utils/health-report.d.ts +2 -3
  48. package/src/lib/app-utils/health-report.js +30 -6
  49. package/src/lib/app-utils/health-report.js.map +1 -1
  50. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  51. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  52. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  53. package/src/lib/app-utils/leaving-notice.d.ts +96 -0
  54. package/src/lib/app-utils/leaving-notice.js +157 -0
  55. package/src/lib/app-utils/leaving-notice.js.map +1 -0
  56. package/src/lib/app-utils/lockdown.d.ts +11 -0
  57. package/src/lib/app-utils/lockdown.js +33 -3
  58. package/src/lib/app-utils/lockdown.js.map +1 -1
  59. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  60. package/src/lib/app-utils/member-email-aliases.js +29 -0
  61. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  62. package/src/lib/app-utils/notifications.d.ts +69 -0
  63. package/src/lib/app-utils/notifications.js +119 -0
  64. package/src/lib/app-utils/notifications.js.map +1 -1
  65. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  66. package/src/lib/app-utils/operator-alerts.js +64 -10
  67. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  68. package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
  69. package/src/lib/app-utils/plan-entitlements.js +200 -224
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  72. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  73. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  74. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  75. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  76. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  77. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  78. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  79. package/src/lib/app-utils/repeat-query.js.map +1 -0
  80. package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
  81. package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
  82. package/src/lib/app-utils/screen-link-context.js +14 -2
  83. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  84. package/src/lib/app-utils/screen-route.d.ts +3 -0
  85. package/src/lib/app-utils/screen-route.js +6 -1
  86. package/src/lib/app-utils/screen-route.js.map +1 -1
  87. package/src/lib/app-utils/server.d.ts +7 -9
  88. package/src/lib/app-utils/server.js +18 -26
  89. package/src/lib/app-utils/server.js.map +1 -1
  90. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  91. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  92. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  93. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  94. package/src/lib/app-utils/starter-templates.js +19 -230
  95. package/src/lib/app-utils/starter-templates.js.map +1 -1
  96. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  97. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  98. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  99. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  100. package/src/lib/app-utils/usage-budget.js +58 -194
  101. package/src/lib/app-utils/usage-budget.js.map +1 -1
  102. package/src/lib/app-utils/utm-attribution.d.ts +1 -1
  103. package/src/lib/app-utils/utm-attribution.js +1 -1
  104. package/src/lib/app-utils/utm-attribution.js.map +1 -1
  105. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
  106. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
  107. package/src/lib/app-utils/utm-touch.js.map +1 -0
  108. package/src/lib/app-utils/variables.d.ts +9 -9
  109. package/src/lib/app-utils/variables.js.map +1 -1
  110. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  111. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  112. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  113. package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
  114. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  115. package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
  116. package/src/lib/plugin-manager/feature-plugins.js +22 -35
  117. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  118. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
  119. package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
  120. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  121. package/src/lib/plugin-manager/index.d.ts +0 -1
  122. package/src/lib/plugin-manager/index.js +0 -1
  123. package/src/lib/plugin-manager/index.js.map +1 -1
  124. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  125. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  126. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  127. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  128. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  129. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  130. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  131. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  132. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  133. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  134. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  135. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  136. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  137. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  138. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  139. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  140. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  141. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  142. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  143. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  144. package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
  145. package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
  146. package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
  147. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  148. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  149. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  150. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  151. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  152. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  153. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
  154. package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
  155. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  156. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  157. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  158. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  159. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  160. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  161. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  162. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  163. package/src/lib/app-utils/assist-credits.js +0 -510
  164. package/src/lib/app-utils/assist-credits.js.map +0 -1
  165. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  166. package/src/lib/app-utils/campaign-membership.js +0 -189
  167. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  168. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  169. package/src/lib/app-utils/console-record-links.d.ts +0 -84
  170. package/src/lib/app-utils/console-record-links.js +0 -76
  171. package/src/lib/app-utils/console-record-links.js.map +0 -1
  172. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  173. package/src/lib/app-utils/crm-booking.js +0 -133
  174. package/src/lib/app-utils/crm-booking.js.map +0 -1
  175. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  176. package/src/lib/app-utils/crm-inbound.js +0 -407
  177. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  178. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  179. package/src/lib/app-utils/crm-reports.js +0 -680
  180. package/src/lib/app-utils/crm-reports.js.map +0 -1
  181. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  182. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  183. package/src/lib/app-utils/dataset-query.js.map +0 -1
  184. package/src/lib/app-utils/email-topics.js.map +0 -1
  185. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  186. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  187. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  188. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -1,680 +0,0 @@
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
- */ /**
17
- * CRM reporting arithmetic (AGL-2604).
18
- *
19
- * The Reports section and the dashboard glance card read Firestore
20
- * aggregates and bounded windows and then have to turn them into a period, a
21
- * set of weekly bars, a funnel and a pipeline total. Every one of those is a
22
- * decision about numbers that a card could get subtly wrong in its own JSX —
23
- * a bucket boundary that is closed on both ends counts a midnight contact
24
- * twice, a funnel that divides by the step before reads 100% where nobody
25
- * arrived, a forecast that weights a closed deal forecasts a sale twice. So
26
- * they live here, pure and specified, and the cards only draw.
27
- *
28
- * Pure data module: no Firestore, no React, no clock — every function that
29
- * needs "now" is handed it.
30
- */ import { _ as _extends } from "@swc/helpers/_/_extends";
31
- import { CONTACT_LIFECYCLE_STAGES, CONTACT_LIFECYCLE_STAGE_LABELS, CRM_ACTIVITY_KINDS, CRM_LEAD_STATUSES, crmLeadStatus, dealStageById, isCrmActivityKind, isPipelineArchived, weightedDealAmountCents } from "./crm.js";
32
- const DAY_MS = 24 * 60 * 60 * 1000;
33
- const WEEK_MS = 7 * DAY_MS;
34
- /**
35
- * The periods a report can be read over.
36
- *
37
- * A fixed list rather than a free date range because every period here is
38
- * also a READ COST: the contact trend runs one count aggregate per week of
39
- * the period, and the closed-deal chart reads the deals closed within it. A
40
- * picker that offered "all time" would offer a read nobody has sized.
41
- */ export const CRM_REPORT_PERIODS = [
42
- '7d',
43
- '30d',
44
- '90d',
45
- 'month'
46
- ];
47
- export const CRM_REPORT_PERIOD_LABELS = {
48
- '7d': 'Last 7 days',
49
- '30d': 'Last 30 days',
50
- '90d': 'Last 90 days',
51
- month: 'This month'
52
- };
53
- /**
54
- * The range a period names at a given moment.
55
- *
56
- * `to` is the moment itself, so a period is always "up to now" and a report
57
- * read twice in one sitting shows the same figures with the newer minutes
58
- * added. "This month" starts on the first of the LOCAL month, because that
59
- * is the month the reader sees on their calendar; the day periods count back
60
- * from the moment rather than from midnight, so "last 7 days" is a full
61
- * seven days rather than six and a fraction.
62
- */ export function crmReportRange(period, nowMs) {
63
- if (period === 'month') {
64
- const now = new Date(nowMs);
65
- const from = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
66
- const previousFrom = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime();
67
- return {
68
- from,
69
- to: nowMs,
70
- previousFrom,
71
- previousTo: from
72
- };
73
- }
74
- const days = period === '7d' ? 7 : period === '30d' ? 30 : 90;
75
- const from = nowMs - days * DAY_MS;
76
- return {
77
- from,
78
- to: nowMs,
79
- previousFrom: from - days * DAY_MS,
80
- previousTo: from
81
- };
82
- }
83
- /**
84
- * The weeks a range divides into: seven-day strides from `from`, the last
85
- * clipped at `to`.
86
- *
87
- * Strides from the START of the period rather than calendar weeks, so that
88
- * "last 7 days" is one bar and "last 30 days" is four full weeks and two
89
- * days — the period the reader picked, in the reader's units — instead of
90
- * whatever fragment of a Monday-to-Sunday week the period happened to start
91
- * inside. An empty or inverted range has no weeks.
92
- */ export function weekBuckets(from, to) {
93
- const buckets = [];
94
- if (!(to > from)) return buckets;
95
- for(let start = from; start < to; start += WEEK_MS){
96
- buckets.push({
97
- start,
98
- end: Math.min(start + WEEK_MS, to)
99
- });
100
- }
101
- return buckets;
102
- }
103
- /**
104
- * The items of a range, grouped into its weeks.
105
- *
106
- * Half-open on every boundary, so an item at exactly midnight between two
107
- * weeks is in the later one and only the later one, and an item at `to` is
108
- * outside the period the way an item at `from` is inside it. Items with no
109
- * usable time are dropped rather than placed anywhere — an undated deal is
110
- * not a deal closed this week. Every week is returned, empty ones included,
111
- * because a chart wants a bar per week and a gap where a week had nothing is
112
- * a chart with the wrong number of bars.
113
- */ export function bucketByWeek(items, atMs, from, to) {
114
- const buckets = weekBuckets(from, to).map((bounds)=>_extends({}, bounds, {
115
- items: []
116
- }));
117
- if (!buckets.length) return buckets;
118
- for (const item of items){
119
- const at = atMs(item);
120
- if (typeof at !== 'number' || !Number.isFinite(at)) continue;
121
- if (at < from || at >= to) continue;
122
- const index = Math.min(buckets.length - 1, Math.floor((at - from) / WEEK_MS));
123
- buckets[index].items.push(item);
124
- }
125
- return buckets;
126
- }
127
- /**
128
- * The lifecycle funnel a set of per-stage counts describes.
129
- *
130
- * A funnel is read CUMULATIVELY: a customer was once a lead, so the "lead"
131
- * step is everyone who is a lead now or has moved past it, not only the
132
- * people currently sitting there. That is what makes the steps narrow as
133
- * they go down and what makes a step-to-step conversion mean something.
134
- * `other` is a stage a merchant chose for a person the funnel does not
135
- * describe, so it is reported beside the funnel and never folded into a
136
- * step. A conversion where the previous step was empty is `null` rather than
137
- * 0 or 100 — there is nothing to divide by, and either number would be read
138
- * as a measurement.
139
- */ export function funnelFromStages(counts) {
140
- const countOf = (stage)=>{
141
- var _counts_stage;
142
- const value = Number((_counts_stage = counts[stage]) != null ? _counts_stage : 0);
143
- return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
144
- };
145
- const progression = CONTACT_LIFECYCLE_STAGES.filter((stage)=>stage !== 'other');
146
- const steps = [];
147
- let reachedAfter = 0;
148
- for(let index = progression.length - 1; index >= 0; index -= 1){
149
- const stage = progression[index];
150
- const count = countOf(stage);
151
- const reached = count + reachedAfter;
152
- steps.unshift({
153
- stage,
154
- label: CONTACT_LIFECYCLE_STAGE_LABELS[stage],
155
- count,
156
- reached,
157
- conversion: null
158
- });
159
- reachedAfter = reached;
160
- }
161
- for(let index = 1; index < steps.length; index += 1){
162
- const previous = steps[index - 1].reached;
163
- steps[index].conversion = previous > 0 ? steps[index].reached / previous : null;
164
- }
165
- const other = countOf('other');
166
- return {
167
- steps,
168
- other,
169
- total: steps.reduce((sum, step)=>sum + step.count, 0) + other
170
- };
171
- }
172
- /**
173
- * What a pipeline holds, by stage.
174
- *
175
- * Only OPEN deals: a won deal is revenue and a lost one is history, and a
176
- * pipeline report that counted either would show a stage full of deals that
177
- * cannot move. Only the pipeline's open-kind stages are rows, for the same
178
- * reason from the other side — a "Won" column in a pipeline chart is the
179
- * closed-deals report drawn in the wrong place. The weighting is
180
- * `weightedDealAmountCents`, applied here rather than restated.
181
- */ export function pipelineTotals(deals, pipeline) {
182
- var _ref;
183
- const stages = [
184
- ...(_ref = pipeline == null ? void 0 : pipeline.stages) != null ? _ref : []
185
- ].filter((stage)=>stage.kind === 'open').sort((a, b)=>a.order - b.order);
186
- const rows = new Map(stages.map((stage)=>[
187
- stage.id,
188
- {
189
- stage,
190
- count: 0,
191
- amountCents: 0,
192
- weightedCents: 0
193
- }
194
- ]));
195
- const totals = {
196
- count: 0,
197
- amountCents: 0,
198
- weightedCents: 0,
199
- stages: stages.map((stage)=>rows.get(stage.id)),
200
- unplaced: {
201
- count: 0,
202
- amountCents: 0
203
- }
204
- };
205
- for (const deal of deals){
206
- var _deal_amountCents;
207
- if (deal.status !== 'open') continue;
208
- const amount = Math.max(0, Math.round(Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0));
209
- totals.count += 1;
210
- totals.amountCents += amount;
211
- const row = rows.get(deal.stageId);
212
- if (!row) {
213
- totals.unplaced.count += 1;
214
- totals.unplaced.amountCents += amount;
215
- continue;
216
- }
217
- const weighted = weightedDealAmountCents(deal, row.stage);
218
- row.count += 1;
219
- row.amountCents += amount;
220
- row.weightedCents += weighted;
221
- totals.weightedCents += weighted;
222
- }
223
- return totals;
224
- }
225
- /** How many months ahead the close-month forecast looks. */ export const FORECAST_MONTHS = 6;
226
- /**
227
- * The next `months` calendar months from the one `nowMs` falls in, each
228
- * `[first of the month, first of the next)` on the reader's calendar.
229
- *
230
- * The current month is the first bucket, whole: a deal expected to close
231
- * on the 3rd when today is the 20th is late, not next month's, and a
232
- * forecast that started at "now" would drop it between the rows. Local
233
- * months rather than UTC, because `expectedCloseAtMs` is stored at local
234
- * noon of the day picked (`dateInputMs`) and a UTC boundary would put a
235
- * deal picked for the 1st into the month before in half the world.
236
- */ export function closeMonthBuckets(nowMs, months = FORECAST_MONTHS) {
237
- const now = new Date(nowMs);
238
- const buckets = [];
239
- for(let offset = 0; offset < months; offset += 1){
240
- const start = new Date(now.getFullYear(), now.getMonth() + offset, 1);
241
- const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 1);
242
- buckets.push({
243
- key: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`,
244
- start: start.getTime(),
245
- end: end.getTime()
246
- });
247
- }
248
- return buckets;
249
- }
250
- const emptyCell = ()=>({
251
- count: 0,
252
- amountCents: 0,
253
- weightedCents: 0
254
- });
255
- function addToCell(cell, amountCents, weightedCents) {
256
- cell.count += 1;
257
- cell.amountCents += amountCents;
258
- cell.weightedCents += weightedCents;
259
- }
260
- /**
261
- * The open pipeline laid out by the month each deal is expected to close
262
- * (AGL-2620): per pipeline, one cell per month for the next
263
- * {@link FORECAST_MONTHS}, at face value and weighted by the deal's stage.
264
- *
265
- * Only open deals, for the reason `pipelineTotals` gives — a closed deal
266
- * has closed. A deal with no `expectedCloseAtMs` is its own row rather than
267
- * being dropped or dated "now": a forecast that quietly omitted undated
268
- * deals would understate the pipeline by exactly the deals nobody has
269
- * scheduled, which is the number a sales lead most wants to see. A deal
270
- * dated before the first month is overdue and a deal dated past the last
271
- * is "later"; both are reported so the rows add up to the pipeline.
272
- *
273
- * Every ACTIVE pipeline is a row even with nothing open — an empty
274
- * pipeline is a fact about the forecast too — while an archived one is a
275
- * row only if a deal still sits open in it. A deal in a pipeline the list
276
- * does not carry at all is grouped under its id with no name, so the
277
- * total still counts it; its stage cannot be resolved, so it weighs
278
- * nothing, as everywhere else.
279
- */ export function forecastByCloseMonth(deals, pipelines, nowMs, months = FORECAST_MONTHS) {
280
- var _ref, _ref1;
281
- var _buckets_, _buckets_1;
282
- const buckets = closeMonthBuckets(nowMs, months);
283
- const byId = new Map();
284
- const pipelineRow = (pipelineId)=>{
285
- let row = byId.get(pipelineId);
286
- if (!row) {
287
- var _ref;
288
- const pipeline = pipelines.find((entry)=>entry.$id === pipelineId);
289
- row = {
290
- pipelineId,
291
- name: (_ref = pipeline == null ? void 0 : pipeline.name) != null ? _ref : '',
292
- months: buckets.map(emptyCell),
293
- undated: emptyCell(),
294
- overdue: emptyCell(),
295
- later: emptyCell(),
296
- total: emptyCell()
297
- };
298
- byId.set(pipelineId, row);
299
- }
300
- return row;
301
- };
302
- for (const pipeline of pipelines){
303
- if (!isPipelineArchived(pipeline)) pipelineRow(pipeline.$id);
304
- }
305
- const totals = {
306
- buckets,
307
- pipelines: [],
308
- months: buckets.map(emptyCell),
309
- undated: emptyCell(),
310
- overdue: emptyCell(),
311
- later: emptyCell(),
312
- total: emptyCell()
313
- };
314
- const first = (_ref = (_buckets_ = buckets[0]) == null ? void 0 : _buckets_.start) != null ? _ref : nowMs;
315
- const last = (_ref1 = (_buckets_1 = buckets[buckets.length - 1]) == null ? void 0 : _buckets_1.end) != null ? _ref1 : nowMs;
316
- for (const deal of deals){
317
- var _deal_pipelineId, _deal_amountCents;
318
- if (deal.status !== 'open') continue;
319
- const pipelineId = String((_deal_pipelineId = deal.pipelineId) != null ? _deal_pipelineId : '');
320
- const row = pipelineRow(pipelineId);
321
- const pipeline = pipelines.find((entry)=>entry.$id === pipelineId);
322
- const amount = Math.max(0, Math.round(Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0));
323
- const weighted = weightedDealAmountCents(deal, dealStageById(pipeline, deal.stageId));
324
- const at = deal.expectedCloseAtMs;
325
- const cells = [
326
- [
327
- row.total,
328
- totals.total
329
- ]
330
- ];
331
- if (typeof at !== 'number' || !Number.isFinite(at)) {
332
- cells.push([
333
- row.undated,
334
- totals.undated
335
- ]);
336
- } else if (at < first) {
337
- cells.push([
338
- row.overdue,
339
- totals.overdue
340
- ]);
341
- } else if (at >= last) {
342
- cells.push([
343
- row.later,
344
- totals.later
345
- ]);
346
- } else {
347
- const index = buckets.findIndex((bucket)=>at >= bucket.start && at < bucket.end);
348
- cells.push([
349
- row.months[index],
350
- totals.months[index]
351
- ]);
352
- }
353
- for (const [own, all] of cells){
354
- addToCell(own, amount, weighted);
355
- addToCell(all, amount, weighted);
356
- }
357
- }
358
- totals.pipelines = [
359
- ...byId.values()
360
- ];
361
- return totals;
362
- }
363
- /**
364
- * How many of a list share each key, most common first.
365
- *
366
- * Ties break on the key so two renders of the same data draw the same order.
367
- * An item with no key is not "a key called empty": a contact whose facet
368
- * names no source is left out of a by-source chart rather than drawn as a
369
- * bar labeled with nothing.
370
- */ export function tally(items, keyOf) {
371
- const counts = new Map();
372
- for (const item of items){
373
- var _counts_get;
374
- const key = keyOf(item);
375
- if (!key) continue;
376
- counts.set(key, ((_counts_get = counts.get(key)) != null ? _counts_get : 0) + 1);
377
- }
378
- return [
379
- ...counts.entries()
380
- ].map(([key, count])=>({
381
- key,
382
- count
383
- })).sort((a, b)=>b.count - a.count || a.key.localeCompare(b.key));
384
- }
385
- /**
386
- * Percentage change against the previous period, to one decimal, or `null`
387
- * when the previous period had nothing.
388
- *
389
- * `null` rather than a number because every number is wrong: "+100%" reads
390
- * as growth, "+0%" reads as flat, and a first contact has no growth rate.
391
- * The tile that renders this draws nothing for `null`, which is the honest
392
- * rendering of "there is no baseline".
393
- */ export function deltaPercent(current, previous) {
394
- if (!(previous > 0)) return null;
395
- return Math.round((current - previous) / previous * 1000) / 10;
396
- }
397
- /**
398
- * The currency a set of deals is in, and whether they are actually in more
399
- * than one.
400
- *
401
- * A pipeline total is a sum, and a sum of dollars and euros is a number
402
- * with no unit. The reports still show that number — a merchant with one
403
- * stray euro deal wants the pipeline figure, not a refusal — but they say
404
- * it is mixed. The most common currency names the total; `usd` is what a
405
- * deal with no currency means, per the model.
406
- */ export function currencyOfDeals(deals) {
407
- var _ref;
408
- var _ranked_;
409
- const ranked = tally(deals, (deal)=>String(deal.currency || 'usd').toLowerCase());
410
- return {
411
- currency: (_ref = (_ranked_ = ranked[0]) == null ? void 0 : _ranked_.key) != null ? _ref : 'usd',
412
- mixed: ranked.length > 1
413
- };
414
- }
415
- /**
416
- * The local calendar day a moment falls in, `[start, end)`.
417
- *
418
- * The same day `taskDueState` decides "today" on, so a count of tasks due
419
- * today and the list's "today" chip agree about the same task at 11 p.m.
420
- */ export function localDayBounds(nowMs) {
421
- const now = new Date(nowMs);
422
- const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
423
- const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
424
- return {
425
- start: start.getTime(),
426
- end: end.getTime()
427
- };
428
- }
429
- /**
430
- * Who did what: the activities and the completed tasks of a period,
431
- * grouped by the person who did them, busiest first.
432
- *
433
- * An activity is credited to whoever logged it. A task is credited to
434
- * whoever COMPLETED it when the route stamped that, and to its assignee
435
- * otherwise: the leaderboard measures work done, and a manager closing out
436
- * a departed teammate's list did that work. A kind the model does not
437
- * name counts as `other` rather than vanishing — the activity happened.
438
- *
439
- * Busiest means activities plus tasks; ties break on activities, then on
440
- * the uid, so two renders of one window draw one order.
441
- */ export function activityLeaderboard(activities, tasksDone) {
442
- const rows = new Map();
443
- /** When each row's name was signed, so a newer signature replaces an older one. */ const namedAtMs = new Map();
444
- const rowFor = (uid)=>{
445
- let row = rows.get(uid);
446
- if (!row) {
447
- row = {
448
- uid,
449
- name: null,
450
- kinds: Object.fromEntries(CRM_ACTIVITY_KINDS.map((kind)=>[
451
- kind,
452
- 0
453
- ])),
454
- activities: 0,
455
- tasksDone: 0
456
- };
457
- rows.set(uid, row);
458
- }
459
- return row;
460
- };
461
- for (const activity of activities){
462
- var _activity_byUid, _activity_byName, _namedAtMs_get;
463
- const row = rowFor(String((_activity_byUid = activity.byUid) != null ? _activity_byUid : ''));
464
- const kind = isCrmActivityKind(activity.kind) ? activity.kind : 'other';
465
- row.kinds[kind] += 1;
466
- row.activities += 1;
467
- const name = String((_activity_byName = activity.byName) != null ? _activity_byName : '').trim();
468
- const atMs = Number(activity.atMs);
469
- if (name && Number.isFinite(atMs) && atMs > ((_namedAtMs_get = namedAtMs.get(row.uid)) != null ? _namedAtMs_get : Number.NEGATIVE_INFINITY)) {
470
- row.name = name;
471
- namedAtMs.set(row.uid, atMs);
472
- }
473
- }
474
- for (const task of tasksDone){
475
- rowFor(String(task.completedByUid || task.assigneeUid || '')).tasksDone += 1;
476
- }
477
- return [
478
- ...rows.values()
479
- ].sort((a, b)=>b.activities + b.tasksDone - (a.activities + a.tasksDone) || b.activities - a.activities || a.uid.localeCompare(b.uid));
480
- }
481
- /*==========================================
482
- * LEAD FUNNEL (AGL-2624)
483
- *=========================================*/ /** The key a lead unqualified with no reason is tallied under. */ export const LEAD_NO_REASON_KEY = '$none';
484
- export const LEAD_NO_REASON_LABEL = 'No reason given';
485
- /**
486
- * Where a set of leads stands, and why the ones that were closed without
487
- * converting were closed.
488
- *
489
- * A status breakdown rather than a cumulative funnel, because a lead's
490
- * history is not on the document — only where it is now — and a lead can
491
- * be qualified straight from `new` without ever having been `working`. A
492
- * step that claimed "reached working" would be inventing a past. The two
493
- * rates are over EVERY lead handed in, so a period in which half the leads
494
- * are still untouched reads as half unconverted, which is what it is.
495
- *
496
- * Reasons are free text — the unqualify dialog requires one but does not
497
- * offer a list — so `Too small` and `too small` are one reason and the
498
- * label is whichever spelling came first. An unqualified lead written past
499
- * the dialog with no reason is counted under its own key rather than
500
- * dropped: the lead was closed, and "no reason given" is the count a
501
- * manager wants to see.
502
- */ export function leadFunnel(leads) {
503
- const byStatus = Object.fromEntries(CRM_LEAD_STATUSES.map((status)=>[
504
- status,
505
- 0
506
- ]));
507
- const reasons = new Map();
508
- for (const lead of leads){
509
- var _lead_unqualifiedReason, _reasons_get;
510
- const status = crmLeadStatus(lead);
511
- byStatus[status] += 1;
512
- if (status !== 'unqualified') continue;
513
- const label = String((_lead_unqualifiedReason = lead.unqualifiedReason) != null ? _lead_unqualifiedReason : '').replace(/\s+/g, ' ').trim();
514
- const key = label ? label.toLowerCase() : LEAD_NO_REASON_KEY;
515
- const row = (_reasons_get = reasons.get(key)) != null ? _reasons_get : {
516
- key,
517
- label: label || LEAD_NO_REASON_LABEL,
518
- count: 0
519
- };
520
- row.count += 1;
521
- reasons.set(key, row);
522
- }
523
- const total = leads.length;
524
- return {
525
- total,
526
- byStatus,
527
- open: byStatus.new + byStatus.working,
528
- qualifiedRate: total > 0 ? byStatus.qualified / total : null,
529
- unqualifiedRate: total > 0 ? byStatus.unqualified / total : null,
530
- reasons: [
531
- ...reasons.values()
532
- ].sort((a, b)=>b.count - a.count || a.label.localeCompare(b.label))
533
- };
534
- }
535
- /**
536
- * The leads still needing somebody, from two server counts: every lead,
537
- * less the ones closed one way or the other.
538
- *
539
- * Subtraction rather than a count of the open statuses because a lead
540
- * nobody has touched carries NO status field — see `crmLeadStatus` — and
541
- * Firestore cannot select a document by a field's absence. The closed
542
- * statuses are always written, so they can be counted; what remains is
543
- * open. Clamped at zero for the moment between the two counts in which a
544
- * lead was closed.
545
- */ export function openLeadsFromCounts(total, closed) {
546
- return Math.max(0, Math.round(Number(total) || 0) - Math.round(Number(closed) || 0));
547
- }
548
- /*==========================================
549
- * CONVERSION BY SOURCE (AGL-2624)
550
- *=========================================*/ /**
551
- * The stages that mean a person has bought, or better.
552
- *
553
- * `customer` and `evangelist` only. `other` sits after `customer` in the
554
- * stage list so that no capture can overwrite it, but it names a step the
555
- * business chose and the report cannot know whether that step is past a
556
- * purchase; counting it would credit a source with a sale nobody recorded.
557
- */ const CUSTOMER_STAGES = new Set([
558
- 'customer',
559
- 'evangelist'
560
- ]);
561
- /**
562
- * Whether this holder's view of a person says they have bought: a stage
563
- * at `customer` or past it, or an order on the books whatever the stage
564
- * says — an order path that never advanced the stage still sold something.
565
- */ export function contactIsCustomer(facet) {
566
- var _facet_ordersCount;
567
- if (facet.lifecycleStage && CUSTOMER_STAGES.has(facet.lifecycleStage)) return true;
568
- const orders = Number((_facet_ordersCount = facet.ordersCount) != null ? _facet_ordersCount : 0);
569
- return Number.isFinite(orders) && orders > 0;
570
- }
571
- /**
572
- * Which capture surfaces turn into customers.
573
- *
574
- * Read through one holder's facet, like every by-source figure: a source
575
- * is which of THIS business's doors the person came through. A person
576
- * captured two ways counts under both — the question is "how well does
577
- * each door convert", and a person who came through two is evidence for
578
- * both — so the rows can add to more than `total`, and `customers` counts
579
- * each person once for the tile above the table. Rows rank by captured,
580
- * not by rate, because a door with one visitor who bought is not the best
581
- * door.
582
- */ export function conversionBySource(facets) {
583
- const rows = new Map();
584
- let unsourced = 0;
585
- let customers = 0;
586
- for (const facet of facets){
587
- var _facet_sources;
588
- const isCustomer = contactIsCustomer(facet);
589
- if (isCustomer) customers += 1;
590
- const sources = Object.keys((_facet_sources = facet.sources) != null ? _facet_sources : {}).filter((source)=>{
591
- var _facet_sources;
592
- return (_facet_sources = facet.sources) == null ? void 0 : _facet_sources[source];
593
- });
594
- if (!sources.length) {
595
- unsourced += 1;
596
- continue;
597
- }
598
- for (const source of sources){
599
- var _rows_get;
600
- const row = (_rows_get = rows.get(source)) != null ? _rows_get : {
601
- source,
602
- captured: 0,
603
- customers: 0,
604
- rate: 0
605
- };
606
- row.captured += 1;
607
- if (isCustomer) row.customers += 1;
608
- rows.set(source, row);
609
- }
610
- }
611
- return {
612
- rows: [
613
- ...rows.values()
614
- ].map((row)=>_extends({}, row, {
615
- rate: row.customers / row.captured
616
- })).sort((a, b)=>b.captured - a.captured || b.customers - a.customers || a.source.localeCompare(b.source)),
617
- unsourced,
618
- customers,
619
- total: facets.length
620
- };
621
- }
622
- /** The key an unowned deal is tallied under — see {@link OwnerOutcomeRow.uid}. */ export const OWNER_UNASSIGNED_KEY = '';
623
- /**
624
- * Who closed what, in the period's window.
625
- *
626
- * The by-owner reading of the same two sets the Won and lost card already
627
- * holds — everything that closed won, and everything that closed lost — so
628
- * this adds no read. Credited to `ownerUid`, the field the board and the
629
- * bulk bar both write, and never to the creator: a deal reassigned mid-cycle
630
- * belongs to whoever carried it at the close, which is what "by owner"
631
- * is asked to mean when a manager reads it.
632
- *
633
- * Ranked by how much CLOSED rather than by win rate, for the reason
634
- * `conversionBySource` ranks by captured: an owner who closed one deal and
635
- * won it is not the best closer on the team. Ties break on wins, then on
636
- * the uid, so two renders of one window draw one order.
637
- */ export function wonLostByOwner(won, lost) {
638
- const rows = new Map();
639
- const rowFor = (uid)=>{
640
- let row = rows.get(uid);
641
- if (!row) {
642
- row = {
643
- uid,
644
- won: 0,
645
- lost: 0,
646
- wonAmountCents: 0,
647
- lostAmountCents: 0,
648
- closed: 0,
649
- winRate: null
650
- };
651
- rows.set(uid, row);
652
- }
653
- return row;
654
- };
655
- /** A missing or negative amount counts as nothing, never as a debit. */ const cents = (deal)=>{
656
- var _deal_amountCents;
657
- return Math.max(0, Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0);
658
- };
659
- for (const deal of won){
660
- var _deal_ownerUid;
661
- const row = rowFor(String((_deal_ownerUid = deal.ownerUid) != null ? _deal_ownerUid : OWNER_UNASSIGNED_KEY));
662
- row.won += 1;
663
- row.closed += 1;
664
- row.wonAmountCents += cents(deal);
665
- }
666
- for (const deal of lost){
667
- var _deal_ownerUid1;
668
- const row = rowFor(String((_deal_ownerUid1 = deal.ownerUid) != null ? _deal_ownerUid1 : OWNER_UNASSIGNED_KEY));
669
- row.lost += 1;
670
- row.closed += 1;
671
- row.lostAmountCents += cents(deal);
672
- }
673
- return [
674
- ...rows.values()
675
- ].map((row)=>_extends({}, row, {
676
- winRate: row.closed ? row.won / row.closed : null
677
- })).sort((a, b)=>b.closed - a.closed || b.won - a.won || a.uid.localeCompare(b.uid));
678
- }
679
-
680
- //# sourceMappingURL=crm-reports.js.map