@wireai/activation 0.13.0 → 0.13.3

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 (82) hide show
  1. package/AGENTS.md +7 -4
  2. package/CHANGELOG.md +341 -4
  3. package/README.md +98 -2
  4. package/dist/analytics/index.d.mts +4 -4
  5. package/dist/analytics/index.d.ts +4 -4
  6. package/dist/analytics/index.js +164 -471
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +162 -469
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +14 -1
  11. package/dist/coachmarks/index.d.ts +14 -1
  12. package/dist/coachmarks/index.js +58 -14
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs +58 -14
  15. package/dist/coachmarks/index.mjs.map +1 -1
  16. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
  17. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
  18. package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
  19. package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
  20. package/dist/index.d.mts +218 -4
  21. package/dist/index.d.ts +218 -4
  22. package/dist/index.js +1104 -888
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +887 -692
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/questionnaire/index.d.mts +42 -10
  27. package/dist/questionnaire/index.d.ts +42 -10
  28. package/dist/questionnaire/index.js +45 -13
  29. package/dist/questionnaire/index.js.map +1 -1
  30. package/dist/questionnaire/index.mjs +45 -13
  31. package/dist/questionnaire/index.mjs.map +1 -1
  32. package/dist/reviews/index.d.mts +4 -4
  33. package/dist/reviews/index.d.ts +4 -4
  34. package/dist/reviews/index.js +39 -8
  35. package/dist/reviews/index.js.map +1 -1
  36. package/dist/reviews/index.mjs +39 -8
  37. package/dist/reviews/index.mjs.map +1 -1
  38. package/dist/showcase/index.js +7 -2
  39. package/dist/showcase/index.js.map +1 -1
  40. package/dist/showcase/index.mjs +7 -2
  41. package/dist/showcase/index.mjs.map +1 -1
  42. package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
  43. package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
  44. package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
  45. package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
  46. package/package.json +4 -1
  47. package/src/OnboardingFlow.tsx +175 -15
  48. package/src/WireOnboarding.tsx +70 -8
  49. package/src/activation/wireActivation.ts +3 -3
  50. package/src/analytics/analyticsEvent.ts +16 -1
  51. package/src/analytics/analyticsFacade.ts +2 -2
  52. package/src/analytics/eventQueue.ts +85 -20
  53. package/src/analytics/reportClientEvent.ts +68 -12
  54. package/src/cards/CardGridSelectCard.tsx +1 -1
  55. package/src/cards/ChipSelectCard.tsx +2 -2
  56. package/src/cards/PermissionCard.tsx +438 -0
  57. package/src/cards/SelectionCard.tsx +1 -1
  58. package/src/cards/index.ts +7 -0
  59. package/src/coachmarks/runtime.ts +62 -2
  60. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  61. package/src/context/deviceId.ts +4 -4
  62. package/src/features/WireFeaturesProvider.tsx +4 -1
  63. package/src/identity/userIdentity.ts +1 -1
  64. package/src/illustrations/defaultIllustrations.tsx +44 -3
  65. package/src/index.ts +38 -0
  66. package/src/permissions/index.ts +64 -0
  67. package/src/permissions/permissionCopy.ts +87 -0
  68. package/src/permissions/permissionEvents.ts +76 -0
  69. package/src/permissions/permissionMemory.ts +88 -0
  70. package/src/permissions/placement.ts +88 -0
  71. package/src/permissions/types.ts +131 -0
  72. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  73. package/src/questionnaire/index.ts +9 -1
  74. package/src/questionnaire/transport.ts +46 -11
  75. package/src/reviews/ReviewGate.tsx +61 -6
  76. package/src/reviews/index.ts +10 -1
  77. package/src/reviews/transport.ts +58 -12
  78. package/src/session/persistedSession.ts +10 -3
  79. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  80. package/src/theme/ThemeContext.tsx +42 -1
  81. package/src/types.ts +66 -4
  82. package/src/utils/submitResult.ts +39 -0
@@ -86,7 +86,7 @@ const DEFAULTS = {
86
86
  /** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
87
87
  const READ_TIMEOUT_MS = 1500;
88
88
 
89
- // ── One storage slot per QUEUE, not per appId (K4) ───────────────────────────────────────────────
89
+ // ── One storage slot per QUEUE, not per appId ────────────────────────────────────────────────────
90
90
  //
91
91
  // The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
92
92
  // the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
@@ -153,10 +153,19 @@ type QueuedItem = { id: number; event: ClientEvent; sig: string };
153
153
  /** Persisted shape — the local id + the (already envelope-stamped) event. `sig` is recomputed on load. */
154
154
  type PersistedItem = { id: number; event: ClientEvent };
155
155
 
156
- const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
156
+ /**
157
+ * The verdict of a read that ran out of time. A DISTINCT value, and never `undefined` — an
158
+ * `undefined` here is byte-identical to "the adapter answered, there is no backlog", and that
159
+ * conflation destroys data silently: `parsePersisted(undefined)` gives `[]`, hydration takes its
160
+ * `length === 0` early return without throwing, and the next `persist()` writes the in-memory
161
+ * pending list over a blob nobody has read — or, with nothing pending, `removeItem`s it outright.
162
+ */
163
+ const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
164
+
165
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
157
166
  let timer: ReturnType<typeof setTimeout>;
158
- const timeout = new Promise<undefined>((resolve) => {
159
- timer = setTimeout(() => resolve(undefined), ms);
167
+ const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
168
+ timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
160
169
  });
161
170
  return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
162
171
  };
@@ -198,7 +207,7 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
198
207
  export const createEventQueue = (options: EventQueueOptions): EventQueue => {
199
208
  const target = options.target;
200
209
  const storage = options.storage;
201
- // One slot per QUEUE (K4): an explicit key is taken verbatim; the appId-derived default is rotated
210
+ // One slot per QUEUE: an explicit key is taken verbatim; the appId-derived default is rotated
202
211
  // to `…#2` when a live queue already holds it, so two instances can never share one backlog.
203
212
  //
204
213
  // Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
@@ -217,6 +226,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
217
226
  let flushing = false;
218
227
  let attempt = 0;
219
228
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
229
+ // The persisted blob exists but has NOT been read yet (the cold-start read blew its deadline and
230
+ // is still in flight). While this is true every write is suppressed: the only thing the queue
231
+ // could write is a view of `pending` that does not include the backlog it has not seen, and
232
+ // `setItem`/`removeItem` would destroy it. Cleared as soon as the slow read settles, whichever
233
+ // way it settles, so a read that ultimately fails resumes normal persistence rather than
234
+ // suppressing it for the life of the process.
235
+ let backlogUnread = false;
220
236
 
221
237
  const resolveEnvelope = (): ContextEnvelope | undefined => {
222
238
  try {
@@ -238,6 +254,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
238
254
  // from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
239
255
  // two identical events enqueued in the same millisecond still collapse (a re-render), while the
240
256
  // same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
257
+ // This stays a NUMBER in the queue (the de-dup signature depends on it); `buildEventsRequest`
258
+ // serializes it to the ISO8601 string the server requires at send time.
241
259
  if (stamped.ts === undefined) stamped.ts = Date.now();
242
260
  if (!env) return stamped;
243
261
  if (!stamped.device && env.device) stamped.device = env.device;
@@ -252,6 +270,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
252
270
 
253
271
  const persist = (): void => {
254
272
  if (!storage) return;
273
+ // NEVER write over a blob that has not been read yet. See `backlogUnread`.
274
+ if (backlogUnread) return;
255
275
  try {
256
276
  if (pending.length === 0) {
257
277
  void storage.removeItem(key).catch(() => {});
@@ -278,25 +298,70 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
278
298
  }
279
299
  };
280
300
 
281
- // Load any persisted backlog. Anything enqueued before this settles stays in memory; we merge
282
- // persisted (older) ahead of it and reassign monotonic ids so dequeue-after-ack is deterministic.
301
+ /**
302
+ * Merge a loaded backlog into the in-memory buffer: persisted (older) events go AHEAD of
303
+ * whatever was enqueued while the read was in flight, duplicates collapse, and the size cap
304
+ * applies as usual.
305
+ *
306
+ * Ids are minted FRESH from the running `nextId` rather than reset to 0. A drain may already be
307
+ * in flight holding a batch of ids it will filter out on ack, and re-numbering from 0 would make
308
+ * those ids point at different events — the ack would then dequeue (silently drop) whichever
309
+ * events happened to inherit them. Monotonic ids are never reused, so an in-flight ack stays
310
+ * correct whatever lands in between.
311
+ */
312
+ const mergePersisted = (persistedItems: PersistedItem[]): void => {
313
+ if (persistedItems.length === 0) return;
314
+ const seen = new Set(pending.map((item) => item.sig));
315
+ const restored: QueuedItem[] = [];
316
+ for (const persisted of persistedItems) {
317
+ const sig = safeSig(persisted.event);
318
+ if (seen.has(sig)) continue; // already in memory — collapse the duplicate
319
+ seen.add(sig);
320
+ restored.push({ id: nextId++, event: persisted.event, sig });
321
+ }
322
+ if (restored.length === 0) return;
323
+ pending = [...restored, ...pending];
324
+ enforceSizeCap();
325
+ persist();
326
+ };
327
+
328
+ // Load any persisted backlog. Anything enqueued before this settles stays in memory; the merge
329
+ // above puts persisted (older) events ahead of it.
330
+ //
331
+ // A read that blows READ_TIMEOUT_MS is NOT treated as "no backlog". The race only unblocks the
332
+ // DRAIN — the original read is kept and merged whenever it lands, and until then every write is
333
+ // suppressed so the unread blob survives intact.
283
334
  const loadPromise: Promise<void> = (async () => {
284
335
  if (!storage) return;
285
336
  try {
286
- const persistedItems = parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
287
- if (persistedItems.length === 0) return;
288
- const events = [...persistedItems.map((p) => p.event), ...pending.map((p) => p.event)];
289
- pending = [];
290
- nextId = 0;
291
- const seen = new Set<string>();
292
- for (const event of events) {
293
- const sig = safeSig(event);
294
- if (seen.has(sig)) continue; // collapse duplicates carried across the merge
295
- seen.add(sig);
296
- pending.push({ id: nextId++, event, sig });
337
+ const read = Promise.resolve(storage.getItem(key));
338
+ // A rejecting read must not surface as an unhandled rejection when the race is won by the
339
+ // timeout; the recovery path below re-attaches its own handlers.
340
+ read.catch(() => {});
341
+ const raced = await withTimeout(read, READ_TIMEOUT_MS);
342
+ if (raced === READ_TIMED_OUT) {
343
+ backlogUnread = true;
344
+ warnInDev(
345
+ `[wireai] the persisted analytics backlog at "${key}" took longer than ${READ_TIMEOUT_MS}ms ` +
346
+ "to read, so the queue started without it. The stored events are NOT discarded: writes " +
347
+ "are held back until the read lands, and the backlog is merged in then. If you see this " +
348
+ "on every cold start, your storage adapter is too slow to be on the launch path.",
349
+ );
350
+ void read
351
+ .then((late) => {
352
+ backlogUnread = false;
353
+ mergePersisted(parsePersisted(late));
354
+ // Send whatever was just recovered; without this it would wait for the next enqueue.
355
+ flush();
356
+ })
357
+ .catch(() => {
358
+ // The slow read ultimately failed → nothing to preserve, resume normal persistence.
359
+ backlogUnread = false;
360
+ persist();
361
+ });
362
+ return;
297
363
  }
298
- enforceSizeCap();
299
- persist();
364
+ mergePersisted(parsePersisted(raced));
300
365
  } catch {
301
366
  // Unreadable backlog → start empty; nothing enqueued in-memory is lost.
302
367
  }
@@ -77,15 +77,24 @@ export type ClientEvent = {
77
77
  */
78
78
  user_id?: string;
79
79
  /**
80
- * Client-stamped epoch-ms timestamp of when the event was ENQUEUED on the device. Optional and
81
- * ADDITIVE: the offline queue stamps it at enqueue time (see `createEventQueue`) so two otherwise
82
- * byte-identical events fired seconds apart (a genuine repeat, e.g. the user taps "share" twice)
83
- * are NOT collapsed by the queue's identical-JSON de-dup while two truly simultaneous
84
- * re-enqueues of the same instant (a redundant re-render) still share a `ts` and collapse. A raw
85
- * `Date.now()`, never a wall-clock the server trusts (the server derives its own receive time);
86
- * an old/strict server that does not model it simply ignores the unknown field.
80
+ * Client-stamped timestamp of when the event was ENQUEUED on the device. Optional and ADDITIVE.
81
+ *
82
+ * TWO REPRESENTATIONS, on purpose:
83
+ * - INTERNAL (epoch-ms `number`): what the offline queue stamps at enqueue time (see
84
+ * `createEventQueue`), so two otherwise byte-identical events fired seconds apart (a genuine
85
+ * repeat, e.g. the user taps "share" twice) are NOT collapsed by the queue's identical-JSON
86
+ * de-dup while two truly simultaneous re-enqueues of the same instant (a redundant
87
+ * re-render) still share a `ts` and collapse. The de-dup signature depends on this number.
88
+ * - WIRE (ISO8601 UTC `string`): what actually leaves the device. {@link buildEventsRequest}
89
+ * converts the number on its way out, because the server declares `ts: str | None` and
90
+ * pydantic v2 does NOT coerce a number into it — a numeric `ts` made the server answer HTTP
91
+ * 200 with `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}`,
92
+ * silently discarding EVERY `app_event` through 0.13.0.
93
+ *
94
+ * A caller-set ISO string is passed through as-is. Never a wall-clock the server trusts (it
95
+ * derives its own receive time); an old/strict server that does not model it ignores the field.
87
96
  */
88
- ts?: number;
97
+ ts?: number | string;
89
98
  };
90
99
 
91
100
  /** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
@@ -105,12 +114,46 @@ export type ClientEventTarget = {
105
114
  export const makeSessionId = (): string =>
106
115
  `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
107
116
 
117
+ /**
118
+ * Serialize the internal epoch-ms `ts` to the ISO8601 UTC string the WIRE requires.
119
+ *
120
+ * The server's event model declares `ts: str | None` and pydantic v2 does NOT coerce int → str, so
121
+ * a numeric `ts` fails per-event validation: the endpoint still answers HTTP 200, but with
122
+ * `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
123
+ * `app_event` from `track()`/`screen()` silently discarded behind a green response.
124
+ *
125
+ * WHY HERE and not at the queue's `stamp()`: this is the single choke point all FOUR send paths go
126
+ * through (offline queue, fire-and-forget, awaitable, session-start). Converting here leaves the
127
+ * queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly as it was, and
128
+ * it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their way out.
129
+ *
130
+ * NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
131
+ * caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
132
+ * or out-of-range number — the latter makes `toISOString` throw — drops the `ts` field from the
133
+ * copy rather than killing the whole batch.
134
+ */
135
+ const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
136
+ events.map((event) => {
137
+ if (typeof event.ts !== "number") return event;
138
+ const { ts, ...rest } = event;
139
+ if (!Number.isFinite(ts)) return rest;
140
+ try {
141
+ return { ...rest, ts: new Date(ts).toISOString() };
142
+ } catch {
143
+ // Out-of-range epoch-ms — send the event WITHOUT a ts rather than lose the batch.
144
+ return rest;
145
+ }
146
+ });
147
+
108
148
  /**
109
149
  * The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
110
150
  * fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
111
151
  * build their request here so there is a SINGLE definition of the events transport — no second
112
152
  * copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
113
153
  * nothing to send (no target / no events) or serialization throws, so callers just bail.
154
+ *
155
+ * It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
156
+ * {@link toWireEvents} for why the conversion belongs at this choke point.
114
157
  */
115
158
  export const buildEventsRequest = (
116
159
  target: { serverUrl: string; apiKey?: string } | undefined,
@@ -121,15 +164,18 @@ export const buildEventsRequest = (
121
164
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
122
165
  const headers: Record<string, string> = { "Content-Type": "application/json" };
123
166
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
124
- return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
167
+ const body = JSON.stringify({ events: toWireEvents(events) });
168
+ return { url, init: { method: "POST", headers, body } };
125
169
  } catch {
126
170
  // URL construction or JSON serialization failed — nothing to send.
127
171
  return null;
128
172
  }
129
173
  };
130
174
 
131
- /** What the `/v1/events` endpoint says in its 200 body. `errors` is the server's planned per-event
132
- * reason list; it is read defensively because no deployed server sends it yet. */
175
+ /** What the `/v1/events` endpoint says in its 200 body. The deployed server DOES send `errors[]`,
176
+ * each entry carrying `{index, reason, field}` `field` names the property that failed validation
177
+ * (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
178
+ * read defensively: an older server sends no `errors` at all. */
133
179
  type EventsAck = { written?: number; skipped: number; reasons: string[] };
134
180
 
135
181
  /**
@@ -153,7 +199,17 @@ const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
153
199
  if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
154
200
  const reasons = Array.isArray(body?.errors)
155
201
  ? body.errors
156
- .map((e) => (e as { reason?: unknown } | null)?.reason)
202
+ .map((e) => {
203
+ const entry = e as { reason?: unknown; field?: unknown } | null;
204
+ const reason = entry?.reason;
205
+ if (typeof reason !== "string") return undefined;
206
+ // `field` is the whole point of a validation error — a bare "validation_error" sends the
207
+ // reader hunting; "validation_error (field: ts)" names the property the server refused.
208
+ const field = entry?.field;
209
+ return typeof field === "string" && field.length > 0
210
+ ? `${reason} (field: ${field})`
211
+ : reason;
212
+ })
157
213
  .filter((r): r is string => typeof r === "string")
158
214
  : [];
159
215
  return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
@@ -104,7 +104,7 @@ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect,
104
104
  const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
105
105
 
106
106
  // Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
107
- // (native driver see EXTRACTED-SPEC.md D7). Reduce motion: snap.
107
+ // (native driver, never a color interpolation). Reduce motion: snap.
108
108
  const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
109
109
  useEffect(() => {
110
110
  const anim = Animated.timing(selectT, {
@@ -80,7 +80,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
80
80
  const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
81
81
 
82
82
  // Selected fill: full accent pill, crossfaded in over 160ms on a native-driver
83
- // opacity overlay (EXTRACTED-SPEC.md D7). Reduce motion: instant swap.
83
+ // opacity overlay, never a color interpolation. Reduce motion: instant swap.
84
84
  const fillT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
85
85
  useEffect(() => {
86
86
  const anim = Animated.timing(fillT, {
@@ -94,7 +94,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
94
94
  }, [isSelected, reduced, fillT]);
95
95
 
96
96
  // Check pop on select (the width-slide of the canvas is a layout animation the
97
- // native driver can't express see EXTRACTED-SPEC.md D4).
97
+ // native driver can't express, so it stays on the JS driver deliberately).
98
98
  const checkT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
99
99
  useEffect(() => {
100
100
  if (!isSelected) {