@iservu-inc/adf-cli 0.9.1 → 0.11.0

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.
@@ -0,0 +1,681 @@
1
+ const storage = require('./storage');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+
5
+ /**
6
+ * Learning Analytics Module
7
+ *
8
+ * Provides comprehensive analytics for the learning system including:
9
+ * - Overview statistics
10
+ * - Skip trends over time
11
+ * - Category preferences
12
+ * - Pattern confidence distribution
13
+ * - Learning effectiveness metrics
14
+ * - Pattern decay status
15
+ * - Session timeline analysis
16
+ * - Most impactful patterns
17
+ * - Question statistics
18
+ */
19
+
20
+ /**
21
+ * Generate complete analytics report
22
+ * @param {string} projectPath - Project root path
23
+ * @returns {Promise<Object>} Complete analytics data
24
+ */
25
+ async function generateAnalytics(projectPath) {
26
+ try {
27
+ // Load all learning data
28
+ const skipHistory = await storage.getSkipHistory(projectPath) || { sessions: [] };
29
+ const answerHistory = await storage.getAnswerHistory(projectPath) || { sessions: [] };
30
+ const patterns = await storage.getPatterns(projectPath) || { patterns: [] };
31
+ const rules = await storage.getLearnedRules(projectPath) || { rules: [] };
32
+ const stats = await storage.getLearningStats(projectPath) || {};
33
+ const config = await storage.getLearningConfig(projectPath) || {};
34
+
35
+ // Calculate all metrics
36
+ const overview = calculateOverviewStats(stats, patterns, rules, skipHistory, answerHistory);
37
+ const skipTrends = calculateSkipTrends(skipHistory);
38
+ const categoryPreferences = calculateCategoryPreferences(skipHistory, answerHistory);
39
+ const patternDistribution = calculatePatternDistribution(patterns.patterns);
40
+ const effectiveness = calculateEffectiveness(skipHistory, patterns.patterns, rules.rules);
41
+ const decayStatus = calculateDecayStatus(patterns.patterns, config);
42
+ const sessionTimeline = calculateSessionTimeline(skipHistory, answerHistory);
43
+ const impactfulPatterns = calculateImpactfulPatterns(patterns.patterns, rules.rules, skipHistory);
44
+ const questionStats = calculateQuestionStats(skipHistory, answerHistory);
45
+
46
+ return {
47
+ version: '1.0',
48
+ exportedAt: new Date().toISOString(),
49
+ exportType: 'learning-analytics',
50
+ dataRange: {
51
+ firstSession: skipHistory.sessions.length > 0 ? skipHistory.sessions[0].timestamp : null,
52
+ lastSession: skipHistory.sessions.length > 0 ? skipHistory.sessions[skipHistory.sessions.length - 1].timestamp : null,
53
+ totalSessions: skipHistory.sessions.length,
54
+ daysSpanned: calculateDaysSpanned(skipHistory.sessions)
55
+ },
56
+ overview,
57
+ skipTrends,
58
+ categoryPreferences,
59
+ patternDistribution,
60
+ effectiveness,
61
+ decayStatus,
62
+ sessionTimeline,
63
+ impactfulPatterns,
64
+ questionStats
65
+ };
66
+ } catch (error) {
67
+ console.error('Error generating analytics:', error.message);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Calculate overview statistics
74
+ * @param {Object} stats - Stats object
75
+ * @param {Object} patterns - Patterns object
76
+ * @param {Object} rules - Rules object
77
+ * @param {Object} skipHistory - Skip history
78
+ * @param {Object} answerHistory - Answer history
79
+ * @returns {Object} Overview statistics
80
+ */
81
+ function calculateOverviewStats(stats, patterns, rules, skipHistory, answerHistory) {
82
+ // Count all patterns except those explicitly marked as removed
83
+ const activePatterns = patterns.patterns.filter(p => !p.status || p.status !== 'removed').length;
84
+ const activeRules = rules.rules.filter(r => r.enabled !== false).length;
85
+
86
+ // Calculate learning system age
87
+ const firstSession = skipHistory.sessions.length > 0 ? skipHistory.sessions[0] : null;
88
+ const learningAge = firstSession ? daysBetween(new Date(firstSession.timestamp), new Date()) : 0;
89
+
90
+ return {
91
+ totalSessions: stats.totalSessions || skipHistory.sessions.length,
92
+ totalSkips: stats.totalSkips || countTotalSkips(skipHistory),
93
+ manualSkips: countManualSkips(skipHistory),
94
+ filteredSkips: countFilteredSkips(skipHistory),
95
+ totalAnswers: stats.totalAnswers || countTotalAnswers(answerHistory),
96
+ activePatterns,
97
+ activeRules,
98
+ learningAge,
99
+ dataSizeMB: 0 // Will be calculated if needed
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Calculate skip trends over last 12 weeks
105
+ * @param {Object} skipHistory - Skip history
106
+ * @returns {Array} Skip trends by week
107
+ */
108
+ function calculateSkipTrends(skipHistory) {
109
+ const weeks = getLast12Weeks();
110
+ const trendData = weeks.map(week => {
111
+ const weekSessions = filterSessionsByWeek(skipHistory.sessions, week);
112
+ const manualSkips = countSkipsByReason(weekSessions, 'manual');
113
+ const filteredSkips = countSkipsByReason(weekSessions, 'filtered');
114
+ const totalAnswers = countAnswersInSessions(weekSessions);
115
+ const totalQuestions = manualSkips + filteredSkips + totalAnswers;
116
+ const skipRate = totalQuestions > 0 ? Math.round((manualSkips + filteredSkips) / totalQuestions * 100) : 0;
117
+
118
+ return {
119
+ week: week.label,
120
+ weekNumber: week.weekNumber,
121
+ manualSkips,
122
+ filteredSkips,
123
+ totalSkips: manualSkips + filteredSkips,
124
+ skipRate
125
+ };
126
+ });
127
+
128
+ return trendData;
129
+ }
130
+
131
+ /**
132
+ * Calculate category preferences (skip rates by category)
133
+ * @param {Object} skipHistory - Skip history
134
+ * @param {Object} answerHistory - Answer history
135
+ * @returns {Array} Category preferences sorted by skip rate
136
+ */
137
+ function calculateCategoryPreferences(skipHistory, answerHistory) {
138
+ const categories = {};
139
+
140
+ // Count skips by category
141
+ for (const session of skipHistory.sessions) {
142
+ for (const skip of session.skips || []) {
143
+ if (!skip.category) continue;
144
+ if (!categories[skip.category]) {
145
+ categories[skip.category] = { skips: 0, answers: 0 };
146
+ }
147
+ categories[skip.category].skips++;
148
+ }
149
+ }
150
+
151
+ // Count answers by category
152
+ for (const session of answerHistory.sessions) {
153
+ for (const answer of session.answers || []) {
154
+ if (!answer.category) continue;
155
+ if (!categories[answer.category]) {
156
+ categories[answer.category] = { skips: 0, answers: 0 };
157
+ }
158
+ categories[answer.category].answers++;
159
+ }
160
+ }
161
+
162
+ // Calculate skip rates
163
+ const preferences = Object.entries(categories).map(([category, data]) => {
164
+ const total = data.skips + data.answers;
165
+ const skipRate = total > 0 ? Math.round((data.skips / total) * 100) : 0;
166
+
167
+ return {
168
+ category,
169
+ skips: data.skips,
170
+ answers: data.answers,
171
+ total,
172
+ skipRate,
173
+ level: getSkipLevel(skipRate)
174
+ };
175
+ });
176
+
177
+ // Sort by skip rate descending
178
+ return preferences.sort((a, b) => b.skipRate - a.skipRate);
179
+ }
180
+
181
+ /**
182
+ * Calculate pattern confidence distribution
183
+ * @param {Array} patterns - Array of patterns
184
+ * @returns {Object} Pattern distribution data
185
+ */
186
+ function calculatePatternDistribution(patterns) {
187
+ const distribution = {
188
+ high: patterns.filter(p => p.confidence >= 90).length,
189
+ medium: patterns.filter(p => p.confidence >= 75 && p.confidence < 90).length,
190
+ low: patterns.filter(p => p.confidence >= 50 && p.confidence < 75).length,
191
+ veryLow: patterns.filter(p => p.confidence < 50).length
192
+ };
193
+
194
+ const avgConfidence = patterns.length > 0
195
+ ? Math.round(patterns.reduce((sum, p) => sum + p.confidence, 0) / patterns.length)
196
+ : 0;
197
+
198
+ const atRisk = patterns.filter(p => p.confidence < 50 || isInactive(p, 5));
199
+
200
+ return {
201
+ distribution,
202
+ avgConfidence,
203
+ totalPatterns: patterns.length,
204
+ atRiskCount: atRisk.length,
205
+ atRiskPatterns: atRisk.map(p => ({
206
+ id: p.id,
207
+ questionId: p.questionId,
208
+ confidence: p.confidence,
209
+ lastSeen: p.lastSeen
210
+ }))
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Calculate learning effectiveness metrics
216
+ * @param {Object} skipHistory - Skip history
217
+ * @param {Array} patterns - Array of patterns
218
+ * @param {Array} rules - Array of rules
219
+ * @returns {Object} Effectiveness metrics
220
+ */
221
+ function calculateEffectiveness(skipHistory, patterns, rules) {
222
+ // Time saved: filtered questions × avg time per question
223
+ const avgTimePerQuestion = 2.5; // minutes
224
+ const questionsFiltered = countFilteredQuestions(skipHistory);
225
+ const timeSavedMinutes = Math.round(questionsFiltered * avgTimePerQuestion);
226
+
227
+ // Pattern accuracy: user-approved patterns / total patterns
228
+ const approvedPatterns = patterns.filter(p => p.userApproved).length;
229
+ const patternAccuracy = patterns.length > 0
230
+ ? Math.round((approvedPatterns / patterns.length) * 100)
231
+ : 0;
232
+
233
+ // Rule application rate
234
+ const totalRuleApplications = rules.reduce((sum, r) => sum + (r.appliedCount || 0), 0);
235
+ const avgApplicationsPerRule = rules.length > 0
236
+ ? Math.round(totalRuleApplications / rules.length)
237
+ : 0;
238
+
239
+ // Overall effectiveness (simple: pattern accuracy)
240
+ const overallEffectiveness = patternAccuracy;
241
+
242
+ return {
243
+ timeSavedMinutes,
244
+ timeSavedHours: (timeSavedMinutes / 60).toFixed(1),
245
+ questionsFiltered,
246
+ falsePositives: 0, // TODO: Implement tracking
247
+ falsePositiveRate: 0,
248
+ patternAccuracy,
249
+ totalRuleApplications,
250
+ avgApplicationsPerRule,
251
+ overallEffectiveness
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Calculate pattern decay status
257
+ * @param {Array} patterns - Array of patterns
258
+ * @param {Object} config - Learning config
259
+ * @returns {Object} Decay status data
260
+ */
261
+ function calculateDecayStatus(patterns, config) {
262
+ const now = new Date();
263
+ const decayStatus = {
264
+ healthy: [],
265
+ warning: [],
266
+ critical: []
267
+ };
268
+
269
+ for (const pattern of patterns) {
270
+ const daysSinceLastSeen = pattern.lastSeen
271
+ ? daysBetween(new Date(pattern.lastSeen), now)
272
+ : 999;
273
+ const monthsInactive = daysSinceLastSeen / 30;
274
+
275
+ // Categorize by confidence and inactivity
276
+ if (pattern.confidence >= 75 && monthsInactive < 3) {
277
+ decayStatus.healthy.push(pattern);
278
+ } else if (pattern.confidence >= 50 && monthsInactive < 5) {
279
+ decayStatus.warning.push(pattern);
280
+ } else {
281
+ decayStatus.critical.push(pattern);
282
+ }
283
+ }
284
+
285
+ // Recently renewed patterns (last 30 days)
286
+ const recentlyRenewed = patterns.filter(p => {
287
+ if (!p.lastSeen) return false;
288
+ const daysSince = daysBetween(new Date(p.lastSeen), now);
289
+ return daysSince <= 30;
290
+ });
291
+
292
+ // Average pattern age
293
+ const avgAge = patterns.length > 0
294
+ ? Math.round(patterns.reduce((sum, p) => {
295
+ if (!p.createdAt) return sum;
296
+ return sum + daysBetween(new Date(p.createdAt), now);
297
+ }, 0) / patterns.length)
298
+ : 0;
299
+
300
+ return {
301
+ healthy: decayStatus.healthy.length,
302
+ warning: decayStatus.warning.length,
303
+ critical: decayStatus.critical.length,
304
+ recentlyRenewed: recentlyRenewed.length,
305
+ avgAge,
306
+ needsAttention: decayStatus.critical.map(p => ({
307
+ id: p.id,
308
+ questionId: p.questionId,
309
+ confidence: p.confidence,
310
+ lastSeen: p.lastSeen
311
+ }))
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Calculate session timeline
317
+ * @param {Object} skipHistory - Skip history
318
+ * @param {Object} answerHistory - Answer history
319
+ * @returns {Object} Session timeline data
320
+ */
321
+ function calculateSessionTimeline(skipHistory, answerHistory) {
322
+ const sessions = skipHistory.sessions;
323
+ const timeline = [];
324
+
325
+ for (const session of sessions) {
326
+ const skipCount = (session.skips || []).length;
327
+ const matchingAnswerSession = answerHistory.sessions.find(
328
+ s => s.sessionId === session.sessionId
329
+ );
330
+ const answerCount = matchingAnswerSession ? (matchingAnswerSession.answers || []).length : 0;
331
+
332
+ timeline.push({
333
+ sessionId: session.sessionId,
334
+ timestamp: session.timestamp,
335
+ projectType: session.projectType,
336
+ frameworks: session.frameworks,
337
+ skipCount,
338
+ answerCount,
339
+ totalQuestions: skipCount + answerCount
340
+ });
341
+ }
342
+
343
+ // Sort by timestamp descending (most recent first)
344
+ timeline.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
345
+
346
+ // Calculate frequency
347
+ let sessionsPerWeek = 0;
348
+ let firstSession = null;
349
+ let lastSession = null;
350
+
351
+ if (sessions.length > 0 && sessions[0].timestamp) {
352
+ firstSession = new Date(sessions[0].timestamp).toISOString().split('T')[0];
353
+
354
+ const lastSessionData = sessions[sessions.length - 1];
355
+ if (lastSessionData.timestamp) {
356
+ lastSession = new Date(lastSessionData.timestamp).toISOString().split('T')[0];
357
+ }
358
+
359
+ if (sessions[0].timestamp && lastSessionData.timestamp) {
360
+ const weeksBetween = Math.ceil(daysBetween(new Date(sessions[0].timestamp), new Date(lastSessionData.timestamp)) / 7);
361
+ sessionsPerWeek = weeksBetween > 0 ? (sessions.length / weeksBetween).toFixed(1) : sessions.length;
362
+ }
363
+ }
364
+
365
+ return {
366
+ timeline,
367
+ sessionsPerWeek,
368
+ totalSessions: sessions.length,
369
+ firstSession,
370
+ lastSession
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Calculate most impactful patterns
376
+ * @param {Array} patterns - Array of patterns
377
+ * @param {Array} rules - Array of rules
378
+ * @param {Object} skipHistory - Skip history
379
+ * @returns {Object} Impactful patterns data
380
+ */
381
+ function calculateImpactfulPatterns(patterns, rules, skipHistory) {
382
+ const impactData = [];
383
+
384
+ for (const pattern of patterns) {
385
+ const matchingRule = rules.find(r => r.patternId === pattern.id);
386
+ const appliedCount = matchingRule ? (matchingRule.appliedCount || 0) : 0;
387
+ const timeSaved = appliedCount * 2.5; // 2.5 min per question
388
+
389
+ impactData.push({
390
+ pattern: {
391
+ id: pattern.id,
392
+ questionId: pattern.questionId,
393
+ questionText: pattern.questionText,
394
+ confidence: pattern.confidence,
395
+ type: pattern.type
396
+ },
397
+ appliedCount,
398
+ timeSaved: Math.round(timeSaved),
399
+ impact: calculateImpact(appliedCount, pattern.confidence)
400
+ });
401
+ }
402
+
403
+ return {
404
+ topByTimeSaved: impactData
405
+ .sort((a, b) => b.timeSaved - a.timeSaved)
406
+ .slice(0, 5),
407
+ topByApplications: impactData
408
+ .sort((a, b) => b.appliedCount - a.appliedCount)
409
+ .slice(0, 5),
410
+ perfectConfidence: patterns.filter(p => p.confidence === 100),
411
+ newestHighConfidence: patterns
412
+ .filter(p => p.confidence >= 90)
413
+ .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
414
+ .slice(0, 5)
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Calculate question statistics
420
+ * @param {Object} skipHistory - Skip history
421
+ * @param {Object} answerHistory - Answer history
422
+ * @returns {Object} Question statistics
423
+ */
424
+ function calculateQuestionStats(skipHistory, answerHistory) {
425
+ const questionData = {};
426
+
427
+ // Aggregate skip data
428
+ for (const session of skipHistory.sessions) {
429
+ for (const skip of session.skips || []) {
430
+ if (!questionData[skip.questionId]) {
431
+ questionData[skip.questionId] = {
432
+ questionId: skip.questionId,
433
+ text: skip.text,
434
+ category: skip.category,
435
+ skips: 0,
436
+ answers: 0,
437
+ totalTimeSpent: 0
438
+ };
439
+ }
440
+ questionData[skip.questionId].skips++;
441
+ }
442
+ }
443
+
444
+ // Aggregate answer data
445
+ for (const session of answerHistory.sessions) {
446
+ for (const answer of session.answers || []) {
447
+ if (!questionData[answer.questionId]) {
448
+ questionData[answer.questionId] = {
449
+ questionId: answer.questionId,
450
+ text: answer.text,
451
+ category: answer.category,
452
+ skips: 0,
453
+ answers: 0,
454
+ totalTimeSpent: 0
455
+ };
456
+ }
457
+ questionData[answer.questionId].answers++;
458
+ questionData[answer.questionId].totalTimeSpent += answer.timeSpent || 0;
459
+ }
460
+ }
461
+
462
+ // Calculate rates and averages
463
+ const stats = Object.values(questionData).map(q => {
464
+ const total = q.skips + q.answers;
465
+ q.skipRate = total > 0 ? Math.round((q.skips / total) * 100) : 0;
466
+ q.avgTimeSpent = q.answers > 0 ? Math.round(q.totalTimeSpent / q.answers) : 0;
467
+ return q;
468
+ });
469
+
470
+ return {
471
+ mostAnswered: stats
472
+ .sort((a, b) => b.answers - a.answers)
473
+ .slice(0, 10),
474
+ mostSkipped: stats
475
+ .sort((a, b) => b.skips - a.skips)
476
+ .slice(0, 10),
477
+ lowestSkipRate: stats
478
+ .filter(q => q.skips + q.answers > 0)
479
+ .sort((a, b) => a.skipRate - b.skipRate)
480
+ .slice(0, 10)
481
+ };
482
+ }
483
+
484
+ // =============================================================================
485
+ // Helper Functions
486
+ // =============================================================================
487
+
488
+ /**
489
+ * Calculate days between two dates
490
+ * @param {Date} date1 - Start date
491
+ * @param {Date} date2 - End date
492
+ * @returns {number} Days between dates
493
+ */
494
+ function daysBetween(date1, date2) {
495
+ return Math.floor((date2 - date1) / (1000 * 60 * 60 * 24));
496
+ }
497
+
498
+ /**
499
+ * Get last 12 weeks
500
+ * @returns {Array} Array of week objects
501
+ */
502
+ function getLast12Weeks() {
503
+ const weeks = [];
504
+ const now = new Date();
505
+
506
+ for (let i = 11; i >= 0; i--) {
507
+ const weekStart = new Date(now);
508
+ weekStart.setDate(now.getDate() - (i * 7));
509
+ const weekEnd = new Date(weekStart);
510
+ weekEnd.setDate(weekStart.getDate() + 6);
511
+
512
+ weeks.push({
513
+ label: `Week ${getWeekNumber(weekStart)}`,
514
+ weekNumber: getWeekNumber(weekStart),
515
+ start: weekStart,
516
+ end: weekEnd
517
+ });
518
+ }
519
+
520
+ return weeks;
521
+ }
522
+
523
+ /**
524
+ * Get week number for a date
525
+ * @param {Date} date - Date
526
+ * @returns {number} Week number
527
+ */
528
+ function getWeekNumber(date) {
529
+ const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
530
+ const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
531
+ return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
532
+ }
533
+
534
+ /**
535
+ * Filter sessions by week
536
+ * @param {Array} sessions - Array of sessions
537
+ * @param {Object} week - Week object
538
+ * @returns {Array} Filtered sessions
539
+ */
540
+ function filterSessionsByWeek(sessions, week) {
541
+ return sessions.filter(session => {
542
+ const sessionDate = new Date(session.timestamp);
543
+ return sessionDate >= week.start && sessionDate <= week.end;
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Count skips by reason in sessions
549
+ * @param {Array} sessions - Array of sessions
550
+ * @param {string} reason - Reason ('manual' or 'filtered')
551
+ * @returns {number} Skip count
552
+ */
553
+ function countSkipsByReason(sessions, reason) {
554
+ return sessions.reduce((count, session) => {
555
+ return count + (session.skips || []).filter(s => s.reason === reason).length;
556
+ }, 0);
557
+ }
558
+
559
+ /**
560
+ * Count answers in sessions
561
+ * @param {Array} sessions - Array of sessions
562
+ * @returns {number} Answer count
563
+ */
564
+ function countAnswersInSessions(sessions) {
565
+ return sessions.reduce((count, session) => {
566
+ return count + (session.answers || []).length;
567
+ }, 0);
568
+ }
569
+
570
+ /**
571
+ * Get skip level from skip rate
572
+ * @param {number} skipRate - Skip rate percentage
573
+ * @returns {string} Level ('high', 'medium', 'low')
574
+ */
575
+ function getSkipLevel(skipRate) {
576
+ if (skipRate >= 70) return 'high';
577
+ if (skipRate >= 40) return 'medium';
578
+ return 'low';
579
+ }
580
+
581
+ /**
582
+ * Check if pattern is inactive
583
+ * @param {Object} pattern - Pattern object
584
+ * @param {number} months - Months threshold
585
+ * @returns {boolean} True if inactive
586
+ */
587
+ function isInactive(pattern, months) {
588
+ if (!pattern.lastSeen) return true;
589
+ const now = new Date();
590
+ const lastSeen = new Date(pattern.lastSeen);
591
+ const monthsInactive = (now - lastSeen) / (1000 * 60 * 60 * 24 * 30);
592
+ return monthsInactive >= months;
593
+ }
594
+
595
+ /**
596
+ * Calculate impact score
597
+ * @param {number} applications - Number of applications
598
+ * @param {number} confidence - Confidence score
599
+ * @returns {number} Impact score
600
+ */
601
+ function calculateImpact(applications, confidence) {
602
+ return Math.round(applications * confidence * 0.01);
603
+ }
604
+
605
+ /**
606
+ * Count total skips
607
+ * @param {Object} skipHistory - Skip history
608
+ * @returns {number} Total skips
609
+ */
610
+ function countTotalSkips(skipHistory) {
611
+ return skipHistory.sessions.reduce((count, session) => {
612
+ return count + (session.skips || []).length;
613
+ }, 0);
614
+ }
615
+
616
+ /**
617
+ * Count manual skips
618
+ * @param {Object} skipHistory - Skip history
619
+ * @returns {number} Manual skip count
620
+ */
621
+ function countManualSkips(skipHistory) {
622
+ return skipHistory.sessions.reduce((count, session) => {
623
+ return count + (session.skips || []).filter(s => s.reason === 'manual').length;
624
+ }, 0);
625
+ }
626
+
627
+ /**
628
+ * Count filtered skips
629
+ * @param {Object} skipHistory - Skip history
630
+ * @returns {number} Filtered skip count
631
+ */
632
+ function countFilteredSkips(skipHistory) {
633
+ return skipHistory.sessions.reduce((count, session) => {
634
+ return count + (session.skips || []).filter(s => s.reason === 'filtered').length;
635
+ }, 0);
636
+ }
637
+
638
+ /**
639
+ * Count total answers
640
+ * @param {Object} answerHistory - Answer history
641
+ * @returns {number} Total answers
642
+ */
643
+ function countTotalAnswers(answerHistory) {
644
+ return answerHistory.sessions.reduce((count, session) => {
645
+ return count + (session.answers || []).length;
646
+ }, 0);
647
+ }
648
+
649
+ /**
650
+ * Count filtered questions
651
+ * @param {Object} skipHistory - Skip history
652
+ * @returns {number} Filtered question count
653
+ */
654
+ function countFilteredQuestions(skipHistory) {
655
+ return countFilteredSkips(skipHistory);
656
+ }
657
+
658
+ /**
659
+ * Calculate days spanned by sessions
660
+ * @param {Array} sessions - Array of sessions
661
+ * @returns {number} Days spanned
662
+ */
663
+ function calculateDaysSpanned(sessions) {
664
+ if (sessions.length === 0) return 0;
665
+ const first = new Date(sessions[0].timestamp);
666
+ const last = new Date(sessions[sessions.length - 1].timestamp);
667
+ return daysBetween(first, last);
668
+ }
669
+
670
+ module.exports = {
671
+ generateAnalytics,
672
+ calculateOverviewStats,
673
+ calculateSkipTrends,
674
+ calculateCategoryPreferences,
675
+ calculatePatternDistribution,
676
+ calculateEffectiveness,
677
+ calculateDecayStatus,
678
+ calculateSessionTimeline,
679
+ calculateImpactfulPatterns,
680
+ calculateQuestionStats
681
+ };