@buoy-gg/events 2.1.1 โ†’ 2.1.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 (47) hide show
  1. package/lib/commonjs/components/EventsCopySettingsView.js +39 -8
  2. package/lib/commonjs/components/EventsModal.js +4 -7
  3. package/lib/commonjs/components/UnifiedEventDetail.js +32 -1
  4. package/lib/commonjs/components/UnifiedEventFilters.js +50 -21
  5. package/lib/commonjs/components/UnifiedEventItem.js +6 -1
  6. package/lib/commonjs/hooks/useUnifiedEvents.js +137 -28
  7. package/lib/commonjs/index.js +6 -0
  8. package/lib/commonjs/stores/unifiedEventStore.js +61 -16
  9. package/lib/commonjs/types/copySettings.js +29 -0
  10. package/lib/commonjs/utils/autoDiscoverEventSources.js +261 -39
  11. package/lib/commonjs/utils/badgeSelectionStorage.js +32 -0
  12. package/lib/commonjs/utils/eventExportFormatter.js +778 -1
  13. package/lib/module/components/EventsCopySettingsView.js +40 -9
  14. package/lib/module/components/EventsModal.js +5 -8
  15. package/lib/module/components/UnifiedEventDetail.js +32 -1
  16. package/lib/module/components/UnifiedEventFilters.js +50 -21
  17. package/lib/module/components/UnifiedEventItem.js +6 -1
  18. package/lib/module/hooks/useUnifiedEvents.js +140 -31
  19. package/lib/module/index.js +4 -1
  20. package/lib/module/stores/unifiedEventStore.js +58 -16
  21. package/lib/module/types/copySettings.js +29 -0
  22. package/lib/module/utils/autoDiscoverEventSources.js +260 -39
  23. package/lib/module/utils/badgeSelectionStorage.js +30 -0
  24. package/lib/module/utils/eventExportFormatter.js +777 -1
  25. package/lib/typescript/components/UnifiedEventFilters.d.ts +3 -0
  26. package/lib/typescript/hooks/useUnifiedEvents.d.ts +2 -0
  27. package/lib/typescript/index.d.ts +1 -1
  28. package/lib/typescript/stores/unifiedEventStore.d.ts +18 -2
  29. package/lib/typescript/types/copySettings.d.ts +25 -1
  30. package/lib/typescript/types/index.d.ts +3 -1
  31. package/lib/typescript/utils/autoDiscoverEventSources.d.ts +17 -0
  32. package/lib/typescript/utils/badgeSelectionStorage.d.ts +9 -0
  33. package/lib/typescript/utils/eventExportFormatter.d.ts +4 -0
  34. package/package.json +3 -3
  35. package/src/components/EventsCopySettingsView.tsx +41 -5
  36. package/src/components/EventsModal.tsx +6 -17
  37. package/src/components/UnifiedEventDetail.tsx +28 -0
  38. package/src/components/UnifiedEventFilters.tsx +88 -21
  39. package/src/components/UnifiedEventItem.tsx +5 -0
  40. package/src/hooks/useUnifiedEvents.ts +153 -25
  41. package/src/index.tsx +4 -0
  42. package/src/stores/unifiedEventStore.ts +58 -12
  43. package/src/types/copySettings.ts +31 -1
  44. package/src/types/index.ts +4 -1
  45. package/src/utils/autoDiscoverEventSources.ts +268 -44
  46. package/src/utils/badgeSelectionStorage.ts +30 -0
  47. package/src/utils/eventExportFormatter.ts +797 -0
@@ -8,6 +8,7 @@ exports.filterEvents = filterEvents;
8
8
  exports.generateExport = generateExport;
9
9
  exports.generateJsonExport = generateJsonExport;
10
10
  exports.generateMarkdownExport = generateMarkdownExport;
11
+ exports.generateMermaidExport = generateMermaidExport;
11
12
  exports.generatePlaintextExport = generatePlaintextExport;
12
13
  exports.getExportSummary = getExportSummary;
13
14
  /**
@@ -55,7 +56,8 @@ const SOURCE_LABELS = {
55
56
  "react-query": "Query",
56
57
  "react-query-query": "Query",
57
58
  "react-query-mutation": "Mutation",
58
- route: "Route"
59
+ route: "Route",
60
+ render: "Render"
59
61
  };
60
62
 
61
63
  // ============================================================================
@@ -1077,6 +1079,779 @@ function generatePlaintextExport(events, settings) {
1077
1079
  return lines.join("\n");
1078
1080
  }
1079
1081
 
1082
+ // ============================================================================
1083
+ // MERMAID SEQUENCE DIAGRAM EXPORT
1084
+ // ============================================================================
1085
+
1086
+ /**
1087
+ * Participant configuration for Mermaid sequence diagrams
1088
+ */
1089
+
1090
+ /**
1091
+ * Participant definitions - map event sources to diagram actors
1092
+ * Organized by client vs server for grouping
1093
+ * Note: Storage types are split for clarity
1094
+ */
1095
+ const MERMAID_PARTICIPANTS = [
1096
+ // Client-side
1097
+ {
1098
+ id: "U",
1099
+ icon: "๐Ÿ‘ค",
1100
+ label: "User",
1101
+ sources: [],
1102
+ group: "client"
1103
+ }, {
1104
+ id: "App",
1105
+ icon: "๐Ÿ“ฑ",
1106
+ label: "App",
1107
+ sources: ["route", "render"],
1108
+ group: "client"
1109
+ }, {
1110
+ id: "Async",
1111
+ icon: "๐Ÿ’พ",
1112
+ label: "AsyncStorage",
1113
+ sources: ["storage-async"],
1114
+ group: "client"
1115
+ }, {
1116
+ id: "MMKV",
1117
+ icon: "โšก",
1118
+ label: "MMKV",
1119
+ sources: ["storage-mmkv"],
1120
+ group: "client"
1121
+ }, {
1122
+ id: "Redux",
1123
+ icon: "๐Ÿ”„",
1124
+ label: "Redux",
1125
+ sources: ["redux"],
1126
+ group: "client"
1127
+ },
1128
+ // Server-side / External
1129
+ {
1130
+ id: "API",
1131
+ icon: "๐ŸŒ",
1132
+ label: "API",
1133
+ sources: ["network"],
1134
+ group: "server"
1135
+ }, {
1136
+ id: "Cache",
1137
+ icon: "๐Ÿ“ฆ",
1138
+ label: "QueryCache",
1139
+ sources: ["react-query", "react-query-query", "react-query-mutation"],
1140
+ group: "server"
1141
+ }];
1142
+
1143
+ /**
1144
+ * Get the Mermaid participant ID for an event source
1145
+ */
1146
+ function getParticipantId(source) {
1147
+ const participant = MERMAID_PARTICIPANTS.find(p => p.sources.includes(source));
1148
+ return participant?.id || "App";
1149
+ }
1150
+
1151
+ /**
1152
+ * Detect which participants are needed based on events
1153
+ */
1154
+ function detectParticipants(events) {
1155
+ const sourcesInUse = new Set();
1156
+ for (const event of events) {
1157
+ sourcesInUse.add(event.source);
1158
+ }
1159
+ const participants = [];
1160
+
1161
+ // Always include User and App
1162
+ participants.push(MERMAID_PARTICIPANTS[0]); // User
1163
+ participants.push(MERMAID_PARTICIPANTS[1]); // App
1164
+
1165
+ // Add other participants based on event sources present
1166
+ for (const participant of MERMAID_PARTICIPANTS.slice(2)) {
1167
+ if (participant.sources.some(s => sourcesInUse.has(s))) {
1168
+ participants.push(participant);
1169
+ }
1170
+ }
1171
+ return participants;
1172
+ }
1173
+
1174
+ /**
1175
+ * Check if we should use participant grouping (boxes)
1176
+ */
1177
+ function shouldUseGroups(participants) {
1178
+ const hasClient = participants.some(p => p.group === "client");
1179
+ const hasServer = participants.some(p => p.group === "server");
1180
+ return hasClient && hasServer && participants.length >= 3;
1181
+ }
1182
+
1183
+ /**
1184
+ * Format duration for Mermaid notes
1185
+ */
1186
+ function formatMermaidDuration(ms) {
1187
+ if (ms < 1000) return `${ms}ms`;
1188
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
1189
+ return `${(ms / 60000).toFixed(1)}m`;
1190
+ }
1191
+
1192
+ /**
1193
+ * Escape text for Mermaid (handle special characters)
1194
+ */
1195
+ function escapeMermaidText(text) {
1196
+ return text.replace(/[#;:]/g, " ") // Remove characters with special meaning
1197
+ .replace(/[\n\r]/g, " ") // Replace newlines with spaces
1198
+ .replace(/"/g, "'") // Replace double quotes with single
1199
+ .replace(/\s+/g, " ") // Collapse multiple spaces
1200
+ .trim();
1201
+ }
1202
+
1203
+ /**
1204
+ * Check if a storage key is internal/devtools tracking
1205
+ * Be conservative - only filter obvious internal keys
1206
+ */
1207
+ function isInternalStorageKey(key) {
1208
+ const internalPatterns = ["last-route", "@dev/last", "__debug", "_persist", "devtools/internal"];
1209
+ const lowerKey = key.toLowerCase();
1210
+ return internalPatterns.some(p => lowerKey.includes(p));
1211
+ }
1212
+
1213
+ /**
1214
+ * Format bytes to human readable size
1215
+ */
1216
+ function formatBytes(bytes) {
1217
+ if (bytes < 1024) return `${bytes}B`;
1218
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1219
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1220
+ }
1221
+
1222
+ /**
1223
+ * Estimate size of data in bytes
1224
+ */
1225
+ function estimateDataSize(data) {
1226
+ try {
1227
+ return JSON.stringify(data).length;
1228
+ } catch {
1229
+ return 0;
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Get canonical URL for duplicate detection (strip timestamps, debug params)
1235
+ */
1236
+ function getCanonicalUrl(url) {
1237
+ try {
1238
+ const urlObj = new URL(url, "http://localhost");
1239
+ // Remove common cache-busting/debug params
1240
+ const paramsToRemove = ["timestamp", "t", "_t", "debug", "client", "version", "cache", "_"];
1241
+ paramsToRemove.forEach(p => urlObj.searchParams.delete(p));
1242
+ return urlObj.pathname + (urlObj.searchParams.toString() ? "?" + urlObj.searchParams.toString() : "");
1243
+ } catch {
1244
+ return url;
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Get a readable short name from a storage key
1250
+ */
1251
+ function getStorageKeyName(key) {
1252
+ // Remove common prefixes
1253
+ let name = key.replace(/^@[\w-]+\//, "") // Remove @prefix/
1254
+ .replace(/^devtools\//, "").replace(/^dev\//, "").replace(/^buoy\//, "").replace(/^pokemon\//, ""); // Remove pokemon/ prefix for this app
1255
+
1256
+ // Get last meaningful segment
1257
+ const segments = name.split("/").filter(Boolean);
1258
+ if (segments.length > 1) {
1259
+ name = segments.slice(-1).join("/"); // Just the last segment
1260
+ }
1261
+
1262
+ // Humanize common patterns
1263
+ name = name.replace(/-/g, " ").replace(/_/g, " ");
1264
+
1265
+ // Title case
1266
+ name = name.split(" ").map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
1267
+
1268
+ // Common renames for clarity
1269
+ if (name.toLowerCase() === "saved") name = "Favorites";
1270
+ return name.substring(0, 20);
1271
+ }
1272
+
1273
+ /**
1274
+ * Extract entity name from URL path (e.g., /pokemon/charizard -> Charizard)
1275
+ */
1276
+ function extractEntityFromPath(url) {
1277
+ // Strip query params first
1278
+ let path = url;
1279
+ try {
1280
+ const urlObj = new URL(url, "http://localhost");
1281
+ path = urlObj.pathname;
1282
+ } catch {
1283
+ // If URL parsing fails, try to strip query params manually
1284
+ const queryIndex = path.indexOf("?");
1285
+ if (queryIndex > 0) {
1286
+ path = path.substring(0, queryIndex);
1287
+ }
1288
+ }
1289
+
1290
+ // Common patterns: /resource/:id, /resource/:name
1291
+ const segments = path.split("/").filter(Boolean);
1292
+ if (segments.length >= 2) {
1293
+ const lastSegment = segments[segments.length - 1];
1294
+ // If it looks like an ID (number or uuid), use the resource name
1295
+ if (/^\d+$/.test(lastSegment) || /^[a-f0-9-]{36}$/i.test(lastSegment)) {
1296
+ return null;
1297
+ }
1298
+ // Humanize the last segment (e.g., charizard -> Charizard)
1299
+ return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
1300
+ }
1301
+ if (segments.length === 1) {
1302
+ return segments[0].charAt(0).toUpperCase() + segments[0].slice(1);
1303
+ }
1304
+ return null;
1305
+ }
1306
+
1307
+ /**
1308
+ * Get human-readable action from HTTP method + path
1309
+ */
1310
+ function getHumanReadableApiAction(method, url) {
1311
+ try {
1312
+ const urlObj = new URL(url, "http://localhost");
1313
+ let path = urlObj.pathname;
1314
+
1315
+ // Handle GraphQL specially
1316
+ if (path.includes("graphql")) {
1317
+ return "GraphQL Request";
1318
+ }
1319
+
1320
+ // Remove API prefixes
1321
+ path = path.replace(/^\/api\/v\d+/, "").replace(/^\/api/, "");
1322
+ const entity = extractEntityFromPath(path);
1323
+ const segments = path.split("/").filter(Boolean);
1324
+ const resource = segments[0] ? segments[0].charAt(0).toUpperCase() + segments[0].slice(1) : "Data";
1325
+
1326
+ // Check for search/query patterns
1327
+ const hasSearchQuery = urlObj.searchParams.has("q") || urlObj.searchParams.has("query") || urlObj.searchParams.has("search");
1328
+
1329
+ // Generate human-readable action based on method
1330
+ switch (method.toUpperCase()) {
1331
+ case "GET":
1332
+ if (hasSearchQuery) {
1333
+ const query = urlObj.searchParams.get("q") || urlObj.searchParams.get("query") || urlObj.searchParams.get("search");
1334
+ if (query) return `Search "${query}"`;
1335
+ return `Search ${resource}`;
1336
+ }
1337
+ if (entity) return `Fetch ${entity}`;
1338
+ return `Fetch ${resource}`;
1339
+ case "POST":
1340
+ if (path.includes("search")) return `Search ${resource}`;
1341
+ if (entity) return `Create ${entity}`;
1342
+ return `Create ${resource}`;
1343
+ case "PUT":
1344
+ case "PATCH":
1345
+ if (entity) return `Update ${entity}`;
1346
+ return `Update ${resource}`;
1347
+ case "DELETE":
1348
+ if (entity) return `Delete ${entity}`;
1349
+ return `Delete ${resource}`;
1350
+ default:
1351
+ return `${method} ${resource}`;
1352
+ }
1353
+ } catch {
1354
+ return `${method} request`;
1355
+ }
1356
+ }
1357
+
1358
+ /**
1359
+ * Get human-readable route label
1360
+ */
1361
+ function getHumanReadableRoute(pathname) {
1362
+ if (pathname === "/" || pathname === "") return "Home";
1363
+ const segments = pathname.split("/").filter(Boolean);
1364
+ if (segments.length === 0) return "Home";
1365
+
1366
+ // Handle common patterns
1367
+ if (segments.length === 1) {
1368
+ // /pokemon -> Pokemon List
1369
+ const name = segments[0].charAt(0).toUpperCase() + segments[0].slice(1);
1370
+ return `${name} List`;
1371
+ }
1372
+ if (segments.length >= 2) {
1373
+ // /pokemon/charizard -> Charizard Details
1374
+ const entity = segments[segments.length - 1];
1375
+ // Check if it's a numeric ID
1376
+ if (/^\d+$/.test(entity)) {
1377
+ const resource = segments[0].charAt(0).toUpperCase() + segments[0].slice(1);
1378
+ return `${resource} #${entity}`;
1379
+ }
1380
+ const entityName = entity.charAt(0).toUpperCase() + entity.slice(1);
1381
+ return `${entityName} Details`;
1382
+ }
1383
+ return pathname;
1384
+ }
1385
+
1386
+ /**
1387
+ * Format a single event as Mermaid sequence messages
1388
+ * Returns both the messages and any notes to add
1389
+ * Returns null if the event should be skipped (internal/devtools)
1390
+ */
1391
+ function formatMermaidEvent(event, _settings, baseTimestamp) {
1392
+ const messages = [];
1393
+ const notes = [];
1394
+ const relativeTime = formatMermaidDuration(event.timestamp - baseTimestamp);
1395
+ switch (event.source) {
1396
+ case "route":
1397
+ {
1398
+ const data = event.originalEvent;
1399
+ const pathname = String(data?.pathname || event.title);
1400
+ const previousPathname = data?.previousPathname;
1401
+ const timeSincePrevious = data?.timeSincePrevious;
1402
+ const routeLabel = getHumanReadableRoute(pathname);
1403
+ const fromLabel = previousPathname ? getHumanReadableRoute(previousPathname) : null;
1404
+
1405
+ // Show navigation with clear from โ†’ to pattern
1406
+ if (fromLabel) {
1407
+ messages.push(`U->>App: ${fromLabel} โ†’ ${routeLabel}`);
1408
+ // Show how long user spent on previous screen
1409
+ if (timeSincePrevious && timeSincePrevious > 0) {
1410
+ notes.push(`Note right of App: ${formatMermaidDuration(timeSincePrevious)} on ${fromLabel}`);
1411
+ }
1412
+ } else {
1413
+ messages.push(`U->>App: Open ${routeLabel}`);
1414
+ }
1415
+ break;
1416
+ }
1417
+ case "network":
1418
+ {
1419
+ const data = event.originalEvent;
1420
+ const method = data?.method || "GET";
1421
+ const url = data?.url || "";
1422
+ const status = data?.status;
1423
+ const duration = data?.duration;
1424
+ const responseData = data?.response || data?.data || data?.body;
1425
+ // Try to get size from various possible fields
1426
+ const responseSize = data?.responseSize || data?.size || data?.contentLength;
1427
+
1428
+ // Get human-readable action
1429
+ const action = getHumanReadableApiAction(method, url);
1430
+ const entity = extractEntityFromPath(url);
1431
+
1432
+ // Request with activation
1433
+ messages.push(`App->>+API: ${action}`);
1434
+
1435
+ // Response with entity context if available
1436
+ if (event.status === "error") {
1437
+ const statusText = status ? `${status} Error` : "Failed";
1438
+ messages.push(`API--x-App: ${statusText}`);
1439
+ } else {
1440
+ let response = entity ? `โœ“ ${entity}` : "โœ“ Success";
1441
+ messages.push(`API-->>-App: ${response}`);
1442
+ }
1443
+
1444
+ // Build note with duration and size
1445
+ const noteParts = [];
1446
+ if (duration) {
1447
+ noteParts.push(formatMermaidDuration(duration));
1448
+ }
1449
+ // Add response size - try explicit size first, then estimate
1450
+ if (responseSize && responseSize > 0) {
1451
+ noteParts.push(formatBytes(responseSize));
1452
+ } else if (responseData) {
1453
+ const size = estimateDataSize(responseData);
1454
+ if (size > 1024) {
1455
+ // Only show if > 1KB
1456
+ noteParts.push(formatBytes(size));
1457
+ }
1458
+ }
1459
+ if (noteParts.length > 0) {
1460
+ notes.push(`Note right of API: ${noteParts.join(" ยท ")}`);
1461
+ }
1462
+ break;
1463
+ }
1464
+ case "storage-async":
1465
+ case "storage-mmkv":
1466
+ {
1467
+ const data = event.originalEvent;
1468
+ const innerData = data?.data;
1469
+ const action = data?.action || "access";
1470
+ const key = innerData?.key || "data";
1471
+
1472
+ // Skip internal devtools storage
1473
+ if (isInternalStorageKey(key)) {
1474
+ return {
1475
+ messages: [],
1476
+ notes: [],
1477
+ skip: true
1478
+ };
1479
+ }
1480
+ const keyName = getStorageKeyName(key);
1481
+ const storeId = event.source === "storage-mmkv" ? "MMKV" : "Async";
1482
+
1483
+ // Helper to get item identifier (handles both objects and strings)
1484
+ const getItemId = item => {
1485
+ if (typeof item === "string") return item;
1486
+ if (typeof item === "number") return String(item);
1487
+ if (typeof item === "object" && item) {
1488
+ if ("name" in item) return String(item.name);
1489
+ if ("id" in item) return String(item.id);
1490
+ }
1491
+ return null;
1492
+ };
1493
+
1494
+ // Helper to capitalize first letter
1495
+ const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
1496
+
1497
+ // Analyze the change to determine action and entity
1498
+ let actionIcon = "";
1499
+ let actionVerb = "";
1500
+ let entityName = "";
1501
+ let countInfo = "";
1502
+ const prevValue = innerData?.prevValue;
1503
+ const newValue = innerData?.value;
1504
+
1505
+ // Handle array comparisons (most common for collections)
1506
+ if (Array.isArray(prevValue) && Array.isArray(newValue)) {
1507
+ const prevLen = prevValue.length;
1508
+ const newLen = newValue.length;
1509
+ countInfo = `${prevLen} โ†’ ${newLen} items`;
1510
+ if (newLen > prevLen) {
1511
+ // Items were added
1512
+ actionIcon = "โž•";
1513
+ actionVerb = "Add";
1514
+ // Find what was added
1515
+ const prevSet = new Set(prevValue.map(getItemId).filter(Boolean));
1516
+ for (const item of newValue) {
1517
+ const id = getItemId(item);
1518
+ if (id && !prevSet.has(id)) {
1519
+ entityName = capitalize(id);
1520
+ break;
1521
+ }
1522
+ }
1523
+ } else if (newLen < prevLen) {
1524
+ // Items were removed
1525
+ actionIcon = "โž–";
1526
+ actionVerb = "Remove";
1527
+ // Find what was removed
1528
+ const newSet = new Set(newValue.map(getItemId).filter(Boolean));
1529
+ for (const item of prevValue) {
1530
+ const id = getItemId(item);
1531
+ if (id && !newSet.has(id)) {
1532
+ entityName = capitalize(id);
1533
+ break;
1534
+ }
1535
+ }
1536
+ } else {
1537
+ // Same length - update
1538
+ actionIcon = "๐Ÿ“";
1539
+ actionVerb = "Update";
1540
+ }
1541
+ } else if (Array.isArray(newValue) && (!prevValue || Array.isArray(prevValue) && prevValue.length === 0)) {
1542
+ // Adding first item(s) - prevValue is null/undefined or empty array
1543
+ actionIcon = "โž•";
1544
+ actionVerb = "Add";
1545
+ countInfo = `โ†’ ${newValue.length} items`;
1546
+ if (newValue.length > 0) {
1547
+ const id = getItemId(newValue[0]);
1548
+ if (id) entityName = capitalize(id);
1549
+ }
1550
+ } else if (Array.isArray(prevValue) && (!newValue || Array.isArray(newValue) && newValue.length === 0)) {
1551
+ // Removing all items
1552
+ actionIcon = "โž–";
1553
+ actionVerb = "Clear";
1554
+ countInfo = `${prevValue.length} โ†’ 0 items`;
1555
+ } else if (action === "getItem") {
1556
+ actionIcon = "๐Ÿ“–";
1557
+ actionVerb = "Load";
1558
+ } else if (action === "removeItem") {
1559
+ actionIcon = "โž–";
1560
+ actionVerb = "Remove";
1561
+ } else if (action === "clear") {
1562
+ actionIcon = "๐Ÿ—‘๏ธ";
1563
+ actionVerb = "Clear";
1564
+ } else {
1565
+ // Default for setItem without prev/new comparison
1566
+ actionIcon = "๐Ÿ’พ";
1567
+ actionVerb = "Save";
1568
+ }
1569
+
1570
+ // Build the message
1571
+ let message;
1572
+ if (entityName) {
1573
+ message = `App->>${storeId}: ${actionIcon} ${actionVerb} ${entityName} โ†’ ${keyName}`;
1574
+ } else {
1575
+ message = `App->>${storeId}: ${actionIcon} ${actionVerb} ${keyName}`;
1576
+ }
1577
+ messages.push(message);
1578
+
1579
+ // Add note with count info
1580
+ if (countInfo) {
1581
+ notes.push(`Note right of ${storeId}: ${countInfo}`);
1582
+ }
1583
+ break;
1584
+ }
1585
+ case "react-query":
1586
+ case "react-query-query":
1587
+ {
1588
+ const data = event.originalEvent;
1589
+ const type = data?.type || "";
1590
+ const queryKey = data?.queryKey;
1591
+ const duration = data?.duration;
1592
+
1593
+ // Format query key as human-readable
1594
+ let keyLabel = "data";
1595
+ if (Array.isArray(queryKey) && queryKey.length > 0) {
1596
+ // Try to make it readable: ["pokemon", "charizard"] -> "Charizard"
1597
+ const lastKey = queryKey[queryKey.length - 1];
1598
+ if (typeof lastKey === "string" && !/^\d+$/.test(lastKey)) {
1599
+ keyLabel = lastKey.charAt(0).toUpperCase() + lastKey.slice(1);
1600
+ } else if (queryKey.length > 1 && typeof queryKey[0] === "string") {
1601
+ keyLabel = queryKey[0].charAt(0).toUpperCase() + queryKey[0].slice(1);
1602
+ }
1603
+ }
1604
+ if (type.includes("start") || type.includes("fetch") || event.status === "pending") {
1605
+ messages.push(`App->>+Cache: Fetch ${keyLabel}`);
1606
+ } else if (event.status === "error") {
1607
+ messages.push(`Cache--x-App: Failed to load`);
1608
+ } else if (event.status === "success") {
1609
+ let response = `โœ“ ${keyLabel} ready`;
1610
+ if (duration) {
1611
+ response += ` (${formatMermaidDuration(duration)})`;
1612
+ }
1613
+ messages.push(`Cache-->>-App: ${response}`);
1614
+ } else if (type.includes("invalidate")) {
1615
+ messages.push(`App->>Cache: Refresh ${keyLabel}`);
1616
+ } else {
1617
+ messages.push(`Cache-->>App: ${escapeMermaidText(type)}`);
1618
+ }
1619
+ break;
1620
+ }
1621
+ case "react-query-mutation":
1622
+ {
1623
+ const data = event.originalEvent;
1624
+ const type = data?.type || "";
1625
+ const mutationKey = data?.mutationKey;
1626
+ const duration = data?.duration;
1627
+ let actionLabel = "Update";
1628
+ if (Array.isArray(mutationKey) && mutationKey.length > 0) {
1629
+ const key = String(mutationKey[0]);
1630
+ // Common mutation patterns
1631
+ if (key.includes("add") || key.includes("create")) actionLabel = "Add";else if (key.includes("delete") || key.includes("remove")) actionLabel = "Delete";else if (key.includes("update") || key.includes("edit")) actionLabel = "Update";else actionLabel = key.charAt(0).toUpperCase() + key.slice(1);
1632
+ }
1633
+ if (type.includes("start") || type.includes("loading") || event.status === "pending") {
1634
+ messages.push(`App->>+Cache: ${actionLabel}`);
1635
+ } else if (event.status === "error") {
1636
+ messages.push(`Cache--x-App: ${actionLabel} failed`);
1637
+ } else if (event.status === "success") {
1638
+ let response = `โœ“ ${actionLabel} complete`;
1639
+ if (duration) {
1640
+ response += ` (${formatMermaidDuration(duration)})`;
1641
+ }
1642
+ messages.push(`Cache-->>-App: ${response}`);
1643
+ }
1644
+ break;
1645
+ }
1646
+ case "redux":
1647
+ {
1648
+ const data = event.originalEvent;
1649
+ const actionType = data?.type || event.title;
1650
+ const hasChange = data?.hasStateChange;
1651
+ const diffSummary = data?.diffSummary;
1652
+
1653
+ // Parse action type into human-readable form
1654
+ // e.g., "cart/addItem" -> "Add Item to Cart"
1655
+ const parts = actionType.split("/");
1656
+ let actionLabel;
1657
+ if (parts.length >= 2) {
1658
+ const slice = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
1659
+ let action = parts[1].replace(/([A-Z])/g, " $1") // camelCase to spaces
1660
+ .replace(/(pending|fulfilled|rejected)$/i, "").trim();
1661
+ action = action.charAt(0).toUpperCase() + action.slice(1);
1662
+ actionLabel = `${action} (${slice})`;
1663
+ } else {
1664
+ actionLabel = escapeMermaidText(actionType).substring(0, 25);
1665
+ }
1666
+ messages.push(`App->>Redux: ${actionLabel}`);
1667
+ if (hasChange === false) {
1668
+ notes.push(`Note right of Redux: No change`);
1669
+ } else if (diffSummary) {
1670
+ notes.push(`Note right of Redux: ${escapeMermaidText(diffSummary).substring(0, 35)}`);
1671
+ }
1672
+ break;
1673
+ }
1674
+ case "render":
1675
+ {
1676
+ const data = event.originalEvent;
1677
+ const renderData = data?.renderData;
1678
+ const componentName = escapeMermaidText(event.title).substring(0, 20);
1679
+ const cause = renderData?.cause || "update";
1680
+
1681
+ // Make cause more human-readable
1682
+ const causeLabel = cause === "mount" ? "mounted" : cause === "props" ? "props changed" : cause === "state" ? "state changed" : cause === "context" ? "context changed" : cause === "parent" ? "parent re-rendered" : cause === "hooks" ? "hook changed" : cause;
1683
+ messages.push(`App->>App: Render ${componentName}`);
1684
+ notes.push(`Note right of App: ${causeLabel}`);
1685
+ break;
1686
+ }
1687
+ default:
1688
+ {
1689
+ messages.push(`App->>App: ${escapeMermaidText(event.title).substring(0, 30)}`);
1690
+ }
1691
+ }
1692
+ return {
1693
+ messages,
1694
+ notes
1695
+ };
1696
+ }
1697
+
1698
+ /**
1699
+ * Generate Mermaid sequence diagram export
1700
+ */
1701
+ function generateMermaidExport(events, settings) {
1702
+ const filtered = filterEvents(events, settings);
1703
+ if (filtered.length === 0) {
1704
+ return "sequenceDiagram\n participant U as ๐Ÿ‘ค User\n participant App as ๐Ÿ“ฑ App\n Note over U,App: No events captured";
1705
+ }
1706
+
1707
+ // Sort by timestamp (oldest first for timeline)
1708
+ const sorted = [...filtered].sort((a, b) => a.timestamp - b.timestamp);
1709
+ const baseTimestamp = sorted[0].timestamp;
1710
+
1711
+ // Detect participants
1712
+ const participants = detectParticipants(sorted);
1713
+ const useGroups = shouldUseGroups(participants);
1714
+ const lines = ["sequenceDiagram"];
1715
+ lines.push(" autonumber");
1716
+ lines.push("");
1717
+
1718
+ // Add participants with optional grouping
1719
+ if (useGroups) {
1720
+ const clientParticipants = participants.filter(p => p.group === "client");
1721
+ const serverParticipants = participants.filter(p => p.group === "server");
1722
+ if (clientParticipants.length > 0) {
1723
+ lines.push(" box rgb(240, 248, 255) Client");
1724
+ for (const p of clientParticipants) {
1725
+ lines.push(` participant ${p.id} as ${p.icon} ${p.label}`);
1726
+ }
1727
+ lines.push(" end");
1728
+ }
1729
+ if (serverParticipants.length > 0) {
1730
+ lines.push(" box rgb(255, 248, 240) External");
1731
+ for (const p of serverParticipants) {
1732
+ lines.push(` participant ${p.id} as ${p.icon} ${p.label}`);
1733
+ }
1734
+ lines.push(" end");
1735
+ }
1736
+ } else {
1737
+ for (const p of participants) {
1738
+ lines.push(` participant ${p.id} as ${p.icon} ${p.label}`);
1739
+ }
1740
+ }
1741
+ lines.push("");
1742
+
1743
+ // Calculate session summary stats
1744
+ const sessionDuration = sorted[sorted.length - 1].timestamp - baseTimestamp;
1745
+ const networkEvents = sorted.filter(e => e.source === "network");
1746
+ const totalBytes = networkEvents.reduce((sum, e) => {
1747
+ const data = e.originalEvent;
1748
+ const size = data?.responseSize || data?.size || 0;
1749
+ return sum + size;
1750
+ }, 0);
1751
+ const errorCount = sorted.filter(e => e.status === "error").length;
1752
+
1753
+ // Build summary parts
1754
+ const summaryParts = [];
1755
+ summaryParts.push(`โฑ๏ธ ${formatMermaidDuration(sessionDuration)}`);
1756
+ summaryParts.push(`๐Ÿ“Š ${sorted.length} events`);
1757
+ if (networkEvents.length > 0) {
1758
+ summaryParts.push(`๐ŸŒ ${networkEvents.length} API calls`);
1759
+ }
1760
+ if (totalBytes > 0) {
1761
+ summaryParts.push(`๐Ÿ“ฆ ${formatBytes(totalBytes)}`);
1762
+ }
1763
+ if (errorCount > 0) {
1764
+ summaryParts.push(`โŒ ${errorCount} errors`);
1765
+ }
1766
+
1767
+ // Add session summary header
1768
+ lines.push(` Note over U,API: ${summaryParts.join(" ยท ")}`);
1769
+ lines.push("");
1770
+
1771
+ // Track for duplicate detection - use canonical URLs (without cache-busting params)
1772
+ const recentNetworkUrls = new Map();
1773
+
1774
+ // Process events
1775
+ let skippedCount = 0;
1776
+ for (const event of sorted) {
1777
+ const result = formatMermaidEvent(event, settings, baseTimestamp);
1778
+
1779
+ // Skip internal/devtools events
1780
+ if (result.skip) {
1781
+ skippedCount++;
1782
+ continue;
1783
+ }
1784
+ const {
1785
+ messages,
1786
+ notes
1787
+ } = result;
1788
+
1789
+ // Check for duplicate network requests using canonical URL
1790
+ let isDuplicate = false;
1791
+ let duplicateInfo = "";
1792
+ if (event.source === "network") {
1793
+ const data = event.originalEvent;
1794
+ const url = data?.url;
1795
+ if (url) {
1796
+ const canonicalUrl = getCanonicalUrl(url);
1797
+ const existing = recentNetworkUrls.get(canonicalUrl);
1798
+ if (existing && event.timestamp - existing.timestamp < 30000) {
1799
+ // 30 second window
1800
+ isDuplicate = true;
1801
+ const timeSince = event.timestamp - existing.timestamp;
1802
+ const timeLabel = formatMermaidDuration(timeSince);
1803
+ duplicateInfo = `โš ๏ธ Already fetched ${timeLabel} ago`;
1804
+ recentNetworkUrls.set(canonicalUrl, {
1805
+ timestamp: event.timestamp,
1806
+ count: existing.count + 1
1807
+ });
1808
+ } else {
1809
+ recentNetworkUrls.set(canonicalUrl, {
1810
+ timestamp: event.timestamp,
1811
+ count: 1
1812
+ });
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ // Wrap duplicates or errors in highlighted rect
1818
+ if (isDuplicate) {
1819
+ lines.push(" rect rgb(255, 245, 230)");
1820
+ lines.push(` Note over App,API: ${duplicateInfo}`);
1821
+ for (const msg of messages) {
1822
+ lines.push(` ${msg}`);
1823
+ }
1824
+ for (const note of notes) {
1825
+ lines.push(` ${note}`);
1826
+ }
1827
+ lines.push(" end");
1828
+ } else if (event.status === "error") {
1829
+ lines.push(" rect rgb(255, 235, 235)");
1830
+ for (const msg of messages) {
1831
+ lines.push(` ${msg}`);
1832
+ }
1833
+ for (const note of notes) {
1834
+ lines.push(` ${note}`);
1835
+ }
1836
+ lines.push(" end");
1837
+ } else {
1838
+ for (const msg of messages) {
1839
+ lines.push(` ${msg}`);
1840
+ }
1841
+ for (const note of notes) {
1842
+ lines.push(` ${note}`);
1843
+ }
1844
+ }
1845
+ }
1846
+
1847
+ // Add note about skipped internal events if any
1848
+ if (skippedCount > 0) {
1849
+ lines.push("");
1850
+ lines.push(` Note over App: ${skippedCount} internal events hidden`);
1851
+ }
1852
+ return lines.join("\n");
1853
+ }
1854
+
1080
1855
  /**
1081
1856
  * Generate export based on settings format
1082
1857
  */
@@ -1088,6 +1863,8 @@ function generateExport(events, settings) {
1088
1863
  return generateJsonExport(events, settings);
1089
1864
  case "plaintext":
1090
1865
  return generatePlaintextExport(events, settings);
1866
+ case "mermaid":
1867
+ return generateMermaidExport(events, settings);
1091
1868
  default:
1092
1869
  return generateMarkdownExport(events, settings);
1093
1870
  }