@stacksjs/defaults 0.74.2 → 0.74.4

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 (40) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-buddy/SKILL.md +53 -3
  3. package/ai/skills/stacks-cloud/SKILL.md +83 -11
  4. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  5. package/ai/skills/stacks-composables/SKILL.md +1 -1
  6. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  7. package/ai/skills/stacks-deploy/SKILL.md +97 -24
  8. package/ai/skills/stacks-orm/SKILL.md +1 -1
  9. package/ai/skills/stacks-types/SKILL.md +1 -1
  10. package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
  11. package/app/Actions/Buddy/CommandsAction.ts +1 -10
  12. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
  13. package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
  14. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
  15. package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
  16. package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
  17. package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
  18. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
  19. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
  20. package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
  21. package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
  23. package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
  24. package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
  25. package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
  26. package/app/Actions/Monitoring/error-provider.ts +130 -0
  27. package/app/Models/EmailIdempotency.ts +4 -1
  28. package/app/Models/EmailSuppression.ts +4 -1
  29. package/app/Models/EmailWebhookEvent.ts +4 -1
  30. package/app/Models/Request.ts +4 -1
  31. package/functions/api-url.test.ts +61 -0
  32. package/functions/api-url.ts +19 -2
  33. package/ide/vscode/package.json +1 -1
  34. package/package.json +2 -2
  35. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
  36. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
  37. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
  38. package/resources/functions/dashboard/sidebar.ts +1 -0
  39. package/routes/dashboard-api.ts +5 -0
  40. package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
@@ -0,0 +1,316 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ abandonedCartSegment,
4
+ isRecoverySegment,
5
+ normalizeAbandonedCarts,
6
+ reachOf,
7
+ recoveryCampaignWriteData,
8
+ validateRecoveryCampaign,
9
+ } from './abandoned-cart-records'
10
+
11
+ const NOW = new Date('2026-09-01T18:00:00.000Z')
12
+
13
+ /** A cart, as the driver hands it over. */
14
+ function cart(overrides: Record<string, unknown> = {}): Record<string, unknown> {
15
+ return {
16
+ id: 1,
17
+ status: 'abandoned',
18
+ total: 84,
19
+ total_items: 3,
20
+ currency: 'usd',
21
+ customer_id: 11,
22
+ updated_at: '2026-09-01 06:00:00',
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ const CUSTOMERS = [
28
+ { id: 11, name: 'Rosa Klein', email: 'Rosa@example.com' },
29
+ { id: 12, name: 'Amir Haddad', email: 'amir@example.com' },
30
+ ]
31
+
32
+ const RECOVERY_CAMPAIGN = {
33
+ id: 5,
34
+ name: 'Left something behind',
35
+ status: 'sent',
36
+ sent_count: 40,
37
+ segment_definition: JSON.stringify(abandonedCartSegment(6, 25)),
38
+ }
39
+
40
+ describe('abandoned cart records', () => {
41
+ test('reads what was left behind, and how long ago', () => {
42
+ const result = normalizeAbandonedCarts(
43
+ [cart()],
44
+ [
45
+ { cart_id: 1, quantity: 2, product_name: 'Pour-over kettle' },
46
+ { cart_id: 1, quantity: 1, product_name: 'Burr grinder' },
47
+ ],
48
+ CUSTOMERS,
49
+ [],
50
+ [],
51
+ { now: NOW },
52
+ )
53
+
54
+ expect(result.records[0]).toMatchObject({
55
+ customerName: 'Rosa Klein',
56
+ itemCount: 3,
57
+ items: ['Pour-over kettle', 'Burr grinder'],
58
+ value: 84,
59
+ currency: 'USD',
60
+ idleHours: 12,
61
+ state: 'abandoned',
62
+ contacted: false,
63
+ })
64
+ })
65
+
66
+ test('a bare database timestamp is read as UTC, not as local time', () => {
67
+ const result = normalizeAbandonedCarts(
68
+ // Two hours before NOW, written the way SQLite writes CURRENT_TIMESTAMP.
69
+ [cart({ updated_at: '2026-09-01 16:00:00' })],
70
+ [],
71
+ CUSTOMERS,
72
+ [],
73
+ [],
74
+ { now: NOW },
75
+ )
76
+
77
+ // Read as local in a UTC-7 browser this is five hours in the future,
78
+ // which clamps to "idle 0h" and empties every age filter on the page.
79
+ expect(result.records[0].idleHours).toBe(2)
80
+ })
81
+
82
+ test('a timestamp that carries its own zone is left alone', () => {
83
+ const result = normalizeAbandonedCarts(
84
+ [cart({ updated_at: '2026-09-01T16:00:00+02:00' })],
85
+ [],
86
+ CUSTOMERS,
87
+ [],
88
+ [],
89
+ { now: NOW },
90
+ )
91
+
92
+ expect(result.records[0].idleHours).toBe(4)
93
+ })
94
+
95
+ test('a cart with no line items still counts what it says it holds', () => {
96
+ const result = normalizeAbandonedCarts([cart({ total_items: 5 })], [], CUSTOMERS, [], [], { now: NOW })
97
+
98
+ expect(result.records[0].itemCount).toBe(5)
99
+ })
100
+
101
+ test('a cart without a customer is a guest, not a blank row', () => {
102
+ const result = normalizeAbandonedCarts([cart({ customer_id: null })], [], CUSTOMERS, [], [], { now: NOW })
103
+
104
+ expect(result.records[0]).toMatchObject({ customerName: 'Guest', customerEmail: '' })
105
+ })
106
+
107
+ test('only campaigns aimed at carts count as recovery campaigns', () => {
108
+ const result = normalizeAbandonedCarts(
109
+ [],
110
+ [],
111
+ [],
112
+ [
113
+ RECOVERY_CAMPAIGN,
114
+ { id: 6, name: 'Weekly newsletter', status: 'sent', segment_definition: JSON.stringify({ operator: 'and', rules: [] }) },
115
+ { id: 7, name: 'No segment at all', status: 'draft', segment_definition: null },
116
+ { id: 8, name: 'Half-written segment', status: 'draft', segment_definition: '{ not json' },
117
+ ],
118
+ [],
119
+ { now: NOW },
120
+ )
121
+
122
+ expect(result.campaigns.map(campaign => campaign.id)).toEqual(['5'])
123
+ // The rules the campaign was written with survive on it, so a sent
124
+ // campaign still says what it was aimed at once those carts are gone.
125
+ expect(result.campaigns[0]).toMatchObject({ idleHours: 6, minimumValue: 25, sentCount: 40 })
126
+ })
127
+
128
+ test('a customer a recovery campaign wrote to is marked as contacted', () => {
129
+ const result = normalizeAbandonedCarts(
130
+ [cart()],
131
+ [],
132
+ CUSTOMERS,
133
+ [RECOVERY_CAMPAIGN],
134
+ // The address is matched case-insensitively: a send records whatever
135
+ // the customer typed, and `Rosa@` and `rosa@` are one person.
136
+ [{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
137
+ { now: NOW },
138
+ )
139
+
140
+ expect(result.records[0].contacted).toBe(true)
141
+ expect(result.summary.contacted).toBe(1)
142
+ })
143
+
144
+ test('a send from an ordinary campaign does not count as contact', () => {
145
+ const result = normalizeAbandonedCarts(
146
+ [cart()],
147
+ [],
148
+ CUSTOMERS,
149
+ [{ id: 6, name: 'Weekly newsletter', status: 'sent', segment_definition: '{}' }],
150
+ [{ campaign_id: 6, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
151
+ { now: NOW },
152
+ )
153
+
154
+ // Otherwise every newsletter subscriber with a cold cart would read as
155
+ // somebody the recovery campaign had already reached.
156
+ expect(result.records[0].contacted).toBe(false)
157
+ })
158
+
159
+ test('a checkout after the email is credited to the campaign', () => {
160
+ const result = normalizeAbandonedCarts(
161
+ [cart({ status: 'converted', updated_at: '2026-09-01 09:00:00' })],
162
+ [],
163
+ CUSTOMERS,
164
+ [RECOVERY_CAMPAIGN],
165
+ [{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
166
+ { now: NOW },
167
+ )
168
+
169
+ expect(result.records[0].state).toBe('recovered')
170
+ expect(result.summary).toMatchObject({ recovered: 1, recoveredValue: 84, open: 0, recoveryRate: 100 })
171
+ })
172
+
173
+ test('a checkout before any email is a sale, not a recovery', () => {
174
+ const result = normalizeAbandonedCarts(
175
+ [cart({ status: 'converted', updated_at: '2026-09-01 06:00:00' })],
176
+ [],
177
+ CUSTOMERS,
178
+ [RECOVERY_CAMPAIGN],
179
+ [{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
180
+ { now: NOW },
181
+ )
182
+
183
+ // Somebody who came back on their own is not the campaign's doing, and
184
+ // crediting them would make every recovery campaign look like it worked.
185
+ expect(result.records[0].state).toBe('abandoned')
186
+ expect(result.summary.recovered).toBe(0)
187
+ })
188
+
189
+ test('the rate counts only carts that were actually chased', () => {
190
+ const result = normalizeAbandonedCarts(
191
+ [
192
+ cart({ id: 1, status: 'converted', updated_at: '2026-09-01 09:00:00' }),
193
+ cart({ id: 2, customer_id: 12, total: 40 }),
194
+ // Never written to, so it is not evidence either way.
195
+ cart({ id: 3, customer_id: null, total: 10 }),
196
+ ],
197
+ [],
198
+ CUSTOMERS,
199
+ [RECOVERY_CAMPAIGN],
200
+ [
201
+ { campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' },
202
+ { campaign_id: 5, recipient: 'amir@example.com', sent_at: '2026-09-01 07:00:00' },
203
+ ],
204
+ { now: NOW },
205
+ )
206
+
207
+ // One recovered, one contacted and still cold: 50%, not 33%.
208
+ expect(result.summary).toMatchObject({ open: 2, recovered: 1, contacted: 1, recoveryRate: 50 })
209
+ expect(result.summary.openValue).toBe(50)
210
+ expect(result.summary.averageValue).toBe(25)
211
+ })
212
+
213
+ test('an expired cart is kept, and says so', () => {
214
+ const result = normalizeAbandonedCarts([cart({ status: 'expired' })], [], CUSTOMERS, [], [], { now: NOW })
215
+
216
+ expect(result.records[0].state).toBe('expired')
217
+ expect(result.summary.open).toBe(1)
218
+ })
219
+
220
+ test('nothing to chase divides by nothing', () => {
221
+ const result = normalizeAbandonedCarts([], [], [], [], [], { now: NOW })
222
+
223
+ expect(result.summary).toMatchObject({ open: 0, recovered: 0, recoveryRate: 0, averageValue: 0 })
224
+ })
225
+ })
226
+
227
+ describe('who a recovery campaign would reach', () => {
228
+ const records = normalizeAbandonedCarts(
229
+ [
230
+ cart({ id: 1, total: 84, updated_at: '2026-09-01 06:00:00' }),
231
+ cart({ id: 2, customer_id: 12, total: 12, updated_at: '2026-09-01 06:00:00' }),
232
+ cart({ id: 3, customer_id: 12, total: 90, updated_at: '2026-09-01 17:30:00' }),
233
+ cart({ id: 4, customer_id: null, total: 200, updated_at: '2026-09-01 06:00:00' }),
234
+ ],
235
+ [],
236
+ CUSTOMERS,
237
+ [],
238
+ [],
239
+ { now: NOW },
240
+ ).records
241
+
242
+ test('counts only carts old enough and worth enough', () => {
243
+ // Cart 2 is too cheap, cart 3 too fresh, cart 4 has nobody to write to.
244
+ expect(reachOf(records, 6, 25)).toEqual({ carts: 1, value: 84 })
245
+ })
246
+
247
+ test('widening the rules widens the audience', () => {
248
+ expect(reachOf(records, 1, 0)).toEqual({ carts: 2, value: 96 })
249
+ })
250
+ })
251
+
252
+ describe('composing a recovery campaign', () => {
253
+ test('stores the rules it was written with', () => {
254
+ const data = recoveryCampaignWriteData({
255
+ name: 'Left something behind',
256
+ subject: 'Your cart is still here',
257
+ idleHours: 6,
258
+ minimumValue: 25,
259
+ })
260
+
261
+ expect(isRecoverySegment(data.segment_definition)).toBe(true)
262
+ expect(JSON.parse(data.segment_definition).rules).toContainEqual({
263
+ field: 'cart.idleHours',
264
+ operator: 'gte',
265
+ value: 6,
266
+ })
267
+ expect(data).toMatchObject({ type: 'email', status: 'draft', template: 'abandoned-cart' })
268
+ })
269
+
270
+ test('a send time makes it scheduled', () => {
271
+ const data = recoveryCampaignWriteData({
272
+ name: 'Left something behind',
273
+ subject: 'Your cart is still here',
274
+ scheduledAt: '2026-09-02 09:00:00',
275
+ })
276
+
277
+ expect(data.status).toBe('scheduled')
278
+ expect(validateRecoveryCampaign(data, NOW)).toBe('')
279
+ })
280
+
281
+ test('refuses a send time that has already passed', () => {
282
+ const data = recoveryCampaignWriteData({
283
+ name: 'Left something behind',
284
+ subject: 'Your cart is still here',
285
+ scheduledAt: '2026-08-30 09:00:00',
286
+ })
287
+
288
+ expect(validateRecoveryCampaign(data, NOW)).toContain('future')
289
+ })
290
+
291
+ test('refuses a campaign with nothing to say', () => {
292
+ const data = recoveryCampaignWriteData({ name: 'Left something behind', subject: '' })
293
+
294
+ expect(validateRecoveryCampaign(data, NOW)).toContain('subject')
295
+ })
296
+
297
+ test('refuses to take its audience from an email list', () => {
298
+ const data = recoveryCampaignWriteData({
299
+ name: 'Left something behind',
300
+ subject: 'Your cart is still here',
301
+ emailListId: 7,
302
+ })
303
+
304
+ // A recovery campaign aimed at a list is a newsletter with a misleading
305
+ // name: it would write to the list, not to the people who left carts.
306
+ expect(validateRecoveryCampaign(data, NOW)).toContain('abandoned carts')
307
+ })
308
+
309
+ test('falls back to a sane idle window rather than zero', () => {
310
+ const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', idleHours: 0 })
311
+
312
+ // An idle window of zero would write to somebody the moment they put
313
+ // something in a cart, while they are still shopping.
314
+ expect(JSON.parse(data.segment_definition).rules[1].value).toBe(4)
315
+ })
316
+ })