@goscribe/server 1.0.11 → 1.1.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.
Files changed (83) hide show
  1. package/ANALYSIS_PROGRESS_SPEC.md +463 -0
  2. package/PROGRESS_QUICK_REFERENCE.md +239 -0
  3. package/dist/lib/ai-session.d.ts +20 -9
  4. package/dist/lib/ai-session.js +316 -80
  5. package/dist/lib/auth.d.ts +35 -2
  6. package/dist/lib/auth.js +88 -15
  7. package/dist/lib/env.d.ts +32 -0
  8. package/dist/lib/env.js +46 -0
  9. package/dist/lib/errors.d.ts +33 -0
  10. package/dist/lib/errors.js +78 -0
  11. package/dist/lib/inference.d.ts +4 -1
  12. package/dist/lib/inference.js +9 -11
  13. package/dist/lib/logger.d.ts +62 -0
  14. package/dist/lib/logger.js +342 -0
  15. package/dist/lib/podcast-prompts.d.ts +43 -0
  16. package/dist/lib/podcast-prompts.js +135 -0
  17. package/dist/lib/pusher.d.ts +1 -0
  18. package/dist/lib/pusher.js +14 -2
  19. package/dist/lib/storage.d.ts +3 -3
  20. package/dist/lib/storage.js +51 -47
  21. package/dist/lib/validation.d.ts +51 -0
  22. package/dist/lib/validation.js +64 -0
  23. package/dist/routers/_app.d.ts +697 -111
  24. package/dist/routers/_app.js +5 -0
  25. package/dist/routers/auth.d.ts +11 -1
  26. package/dist/routers/chat.d.ts +11 -1
  27. package/dist/routers/flashcards.d.ts +205 -6
  28. package/dist/routers/flashcards.js +144 -66
  29. package/dist/routers/members.d.ts +165 -0
  30. package/dist/routers/members.js +531 -0
  31. package/dist/routers/podcast.d.ts +78 -63
  32. package/dist/routers/podcast.js +330 -393
  33. package/dist/routers/studyguide.d.ts +11 -1
  34. package/dist/routers/worksheets.d.ts +124 -13
  35. package/dist/routers/worksheets.js +123 -50
  36. package/dist/routers/workspace.d.ts +213 -26
  37. package/dist/routers/workspace.js +303 -181
  38. package/dist/server.js +12 -4
  39. package/dist/services/flashcard-progress.service.d.ts +183 -0
  40. package/dist/services/flashcard-progress.service.js +383 -0
  41. package/dist/services/flashcard.service.d.ts +183 -0
  42. package/dist/services/flashcard.service.js +224 -0
  43. package/dist/services/podcast-segment-reorder.d.ts +0 -0
  44. package/dist/services/podcast-segment-reorder.js +107 -0
  45. package/dist/services/podcast.service.d.ts +0 -0
  46. package/dist/services/podcast.service.js +326 -0
  47. package/dist/services/worksheet.service.d.ts +0 -0
  48. package/dist/services/worksheet.service.js +295 -0
  49. package/dist/trpc.d.ts +13 -2
  50. package/dist/trpc.js +55 -6
  51. package/dist/types/index.d.ts +126 -0
  52. package/dist/types/index.js +1 -0
  53. package/package.json +3 -2
  54. package/prisma/schema.prisma +142 -4
  55. package/src/lib/ai-session.ts +356 -85
  56. package/src/lib/auth.ts +113 -19
  57. package/src/lib/env.ts +59 -0
  58. package/src/lib/errors.ts +92 -0
  59. package/src/lib/inference.ts +11 -11
  60. package/src/lib/logger.ts +405 -0
  61. package/src/lib/pusher.ts +15 -3
  62. package/src/lib/storage.ts +56 -51
  63. package/src/lib/validation.ts +75 -0
  64. package/src/routers/_app.ts +5 -0
  65. package/src/routers/chat.ts +2 -23
  66. package/src/routers/flashcards.ts +108 -24
  67. package/src/routers/members.ts +586 -0
  68. package/src/routers/podcast.ts +385 -420
  69. package/src/routers/worksheets.ts +117 -35
  70. package/src/routers/workspace.ts +328 -195
  71. package/src/server.ts +13 -4
  72. package/src/services/flashcard-progress.service.ts +541 -0
  73. package/src/trpc.ts +59 -6
  74. package/src/types/index.ts +165 -0
  75. package/AUTH_FRONTEND_SPEC.md +0 -21
  76. package/CHAT_FRONTEND_SPEC.md +0 -474
  77. package/DATABASE_SETUP.md +0 -165
  78. package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
  79. package/PODCAST_FRONTEND_SPEC.md +0 -595
  80. package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
  81. package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
  82. package/WORKSPACE_FRONTEND_SPEC.md +0 -47
  83. package/test-ai-integration.js +0 -134
@@ -0,0 +1,183 @@
1
+ import type { PrismaClient } from '@prisma/client';
2
+ /**
3
+ * SM-2 Spaced Repetition Algorithm
4
+ * https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
5
+ */
6
+ export interface SM2Result {
7
+ easeFactor: number;
8
+ interval: number;
9
+ repetitions: number;
10
+ nextReviewAt: Date;
11
+ }
12
+ export declare class FlashcardProgressService {
13
+ private db;
14
+ constructor(db: PrismaClient);
15
+ /**
16
+ * Calculate next review using SM-2 algorithm with smart scheduling
17
+ * @param quality - 0-5 rating (0=complete blackout, 5=perfect response)
18
+ * @param easeFactor - Current ease factor (default 2.5)
19
+ * @param interval - Current interval in days (default 0)
20
+ * @param repetitions - Number of consecutive correct responses (default 0)
21
+ * @param consecutiveIncorrect - Number of consecutive failures (for smart scheduling)
22
+ * @param totalIncorrect - Total incorrect count (for context)
23
+ */
24
+ calculateSM2(quality: number, easeFactor?: number, interval?: number, repetitions?: number, consecutiveIncorrect?: number, totalIncorrect?: number): SM2Result;
25
+ /**
26
+ * Infer confidence level based on consecutive incorrect attempts
27
+ */
28
+ inferConfidence(isCorrect: boolean, consecutiveIncorrect: number, timesStudied: number): 'easy' | 'medium' | 'hard';
29
+ /**
30
+ * Convert confidence to SM-2 quality rating
31
+ */
32
+ confidenceToQuality(confidence: 'easy' | 'medium' | 'hard'): number;
33
+ /**
34
+ * Record flashcard study attempt
35
+ */
36
+ recordStudyAttempt(data: {
37
+ userId: string;
38
+ flashcardId: string;
39
+ isCorrect: boolean;
40
+ confidence?: 'easy' | 'medium' | 'hard';
41
+ timeSpentMs?: number;
42
+ }): Promise<{
43
+ flashcard: {
44
+ id: string;
45
+ createdAt: Date;
46
+ artifactId: string;
47
+ order: number;
48
+ front: string;
49
+ back: string;
50
+ tags: string[];
51
+ };
52
+ } & {
53
+ id: string;
54
+ createdAt: Date;
55
+ updatedAt: Date;
56
+ userId: string;
57
+ flashcardId: string;
58
+ timesStudied: number;
59
+ timesCorrect: number;
60
+ timesIncorrect: number;
61
+ timesIncorrectConsecutive: number;
62
+ easeFactor: number;
63
+ interval: number;
64
+ repetitions: number;
65
+ masteryLevel: number;
66
+ lastStudiedAt: Date | null;
67
+ nextReviewAt: Date | null;
68
+ }>;
69
+ /**
70
+ * Get user's progress on all flashcards in a set
71
+ */
72
+ getSetProgress(userId: string, artifactId: string): Promise<{
73
+ flashcardId: any;
74
+ front: any;
75
+ back: any;
76
+ progress: {
77
+ id: string;
78
+ createdAt: Date;
79
+ updatedAt: Date;
80
+ userId: string;
81
+ flashcardId: string;
82
+ timesStudied: number;
83
+ timesCorrect: number;
84
+ timesIncorrect: number;
85
+ timesIncorrectConsecutive: number;
86
+ easeFactor: number;
87
+ interval: number;
88
+ repetitions: number;
89
+ masteryLevel: number;
90
+ lastStudiedAt: Date | null;
91
+ nextReviewAt: Date | null;
92
+ } | null;
93
+ }[]>;
94
+ /**
95
+ * Get flashcards due for review, non-studied flashcards, and flashcards with low mastery
96
+ */
97
+ getDueFlashcards(userId: string, workspaceId: string): Promise<({
98
+ artifact: {
99
+ id: string;
100
+ createdAt: Date;
101
+ updatedAt: Date;
102
+ title: string;
103
+ description: string | null;
104
+ workspaceId: string;
105
+ type: import("@prisma/client").$Enums.ArtifactType;
106
+ isArchived: boolean;
107
+ generating: boolean;
108
+ generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
109
+ difficulty: import("@prisma/client").$Enums.Difficulty | null;
110
+ estimatedTime: string | null;
111
+ imageObjectKey: string | null;
112
+ createdById: string | null;
113
+ };
114
+ } & {
115
+ id: string;
116
+ createdAt: Date;
117
+ artifactId: string;
118
+ order: number;
119
+ front: string;
120
+ back: string;
121
+ tags: string[];
122
+ })[]>;
123
+ /**
124
+ * Get user statistics for a flashcard set
125
+ */
126
+ getSetStatistics(userId: string, artifactId: string): Promise<{
127
+ totalCards: number;
128
+ studiedCards: number;
129
+ unstudiedCards: number;
130
+ masteredCards: number;
131
+ dueForReview: number;
132
+ averageMastery: number;
133
+ successRate: number;
134
+ totalAttempts: number;
135
+ totalCorrect: number;
136
+ }>;
137
+ /**
138
+ * Reset progress for a flashcard
139
+ */
140
+ resetProgress(userId: string, flashcardId: string): Promise<import("@prisma/client").Prisma.BatchPayload>;
141
+ /**
142
+ * Bulk record study session
143
+ */
144
+ recordStudySession(data: {
145
+ userId: string;
146
+ attempts: Array<{
147
+ flashcardId: string;
148
+ isCorrect: boolean;
149
+ confidence?: 'easy' | 'medium' | 'hard';
150
+ timeSpentMs?: number;
151
+ }>;
152
+ }): Promise<({
153
+ flashcard: {
154
+ id: string;
155
+ createdAt: Date;
156
+ artifactId: string;
157
+ order: number;
158
+ front: string;
159
+ back: string;
160
+ tags: string[];
161
+ };
162
+ } & {
163
+ id: string;
164
+ createdAt: Date;
165
+ updatedAt: Date;
166
+ userId: string;
167
+ flashcardId: string;
168
+ timesStudied: number;
169
+ timesCorrect: number;
170
+ timesIncorrect: number;
171
+ timesIncorrectConsecutive: number;
172
+ easeFactor: number;
173
+ interval: number;
174
+ repetitions: number;
175
+ masteryLevel: number;
176
+ lastStudiedAt: Date | null;
177
+ nextReviewAt: Date | null;
178
+ })[]>;
179
+ }
180
+ /**
181
+ * Factory function
182
+ */
183
+ export declare function createFlashcardProgressService(db: PrismaClient): FlashcardProgressService;
@@ -0,0 +1,383 @@
1
+ import { NotFoundError } from '../lib/errors.js';
2
+ export class FlashcardProgressService {
3
+ constructor(db) {
4
+ this.db = db;
5
+ }
6
+ /**
7
+ * Calculate next review using SM-2 algorithm with smart scheduling
8
+ * @param quality - 0-5 rating (0=complete blackout, 5=perfect response)
9
+ * @param easeFactor - Current ease factor (default 2.5)
10
+ * @param interval - Current interval in days (default 0)
11
+ * @param repetitions - Number of consecutive correct responses (default 0)
12
+ * @param consecutiveIncorrect - Number of consecutive failures (for smart scheduling)
13
+ * @param totalIncorrect - Total incorrect count (for context)
14
+ */
15
+ calculateSM2(quality, easeFactor = 2.5, interval = 0, repetitions = 0, consecutiveIncorrect = 0, totalIncorrect = 0) {
16
+ // If quality < 3, determine if immediate review or short delay
17
+ if (quality < 3) {
18
+ // If no consecutive failures but has some overall failures, give short delay
19
+ const shouldDelayReview = consecutiveIncorrect === 0 && totalIncorrect > 0;
20
+ const nextReviewAt = new Date();
21
+ if (shouldDelayReview) {
22
+ // Give them a few hours to let it sink in
23
+ nextReviewAt.setHours(nextReviewAt.getHours() + 4);
24
+ }
25
+ // Otherwise immediate review (consecutiveIncorrect > 0 or first failure)
26
+ return {
27
+ easeFactor: Math.max(1.3, easeFactor - 0.2),
28
+ interval: 0,
29
+ repetitions: 0,
30
+ nextReviewAt,
31
+ };
32
+ }
33
+ // Calculate new ease factor
34
+ const newEaseFactor = Math.max(1.3, easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
35
+ // Calculate new interval based on performance history
36
+ let newInterval;
37
+ if (repetitions === 0) {
38
+ // First correct answer
39
+ if (consecutiveIncorrect >= 2 || totalIncorrect >= 5) {
40
+ // If they struggled a lot, start conservative
41
+ newInterval = 1; // 1 day
42
+ }
43
+ else if (totalIncorrect === 0) {
44
+ // Perfect card, never failed
45
+ newInterval = 3; // 3 days (skip ahead)
46
+ }
47
+ else {
48
+ // Normal case
49
+ newInterval = 1; // 1 day
50
+ }
51
+ }
52
+ else if (repetitions === 1) {
53
+ newInterval = 6; // 6 days
54
+ }
55
+ else {
56
+ newInterval = Math.ceil(interval * newEaseFactor);
57
+ }
58
+ // Calculate next review date
59
+ const nextReviewAt = new Date();
60
+ nextReviewAt.setDate(nextReviewAt.getDate() + newInterval);
61
+ return {
62
+ easeFactor: newEaseFactor,
63
+ interval: newInterval,
64
+ repetitions: repetitions + 1,
65
+ nextReviewAt,
66
+ };
67
+ }
68
+ /**
69
+ * Infer confidence level based on consecutive incorrect attempts
70
+ */
71
+ inferConfidence(isCorrect, consecutiveIncorrect, timesStudied) {
72
+ if (!isCorrect) {
73
+ // If they got it wrong, it's obviously hard
74
+ return 'hard';
75
+ }
76
+ // If they got it right but have high consecutive failures, it's still hard
77
+ if (consecutiveIncorrect >= 3) {
78
+ return 'hard';
79
+ }
80
+ if (consecutiveIncorrect >= 1) {
81
+ return 'medium';
82
+ }
83
+ // If first time or low failure history, check overall performance
84
+ if (timesStudied === 0 || timesStudied === 1) {
85
+ return 'medium'; // Default for first attempts
86
+ }
87
+ // If they've studied it multiple times with no recent failures, it's easy
88
+ return 'easy';
89
+ }
90
+ /**
91
+ * Convert confidence to SM-2 quality rating
92
+ */
93
+ confidenceToQuality(confidence) {
94
+ switch (confidence) {
95
+ case 'easy':
96
+ return 5; // Perfect response
97
+ case 'medium':
98
+ return 4; // Correct after hesitation
99
+ case 'hard':
100
+ return 3; // Correct with difficulty
101
+ default:
102
+ return 4;
103
+ }
104
+ }
105
+ /**
106
+ * Record flashcard study attempt
107
+ */
108
+ async recordStudyAttempt(data) {
109
+ const { userId, flashcardId, isCorrect, timeSpentMs } = data;
110
+ // Verify flashcard exists and user has access
111
+ const flashcard = await this.db.flashcard.findFirst({
112
+ where: {
113
+ id: flashcardId,
114
+ artifact: {
115
+ workspace: {
116
+ OR: [
117
+ { ownerId: userId },
118
+ { members: { some: { userId } } },
119
+ ],
120
+ },
121
+ },
122
+ },
123
+ });
124
+ if (!flashcard) {
125
+ throw new NotFoundError('Flashcard');
126
+ }
127
+ // Get existing progress
128
+ const existingProgress = await this.db.flashcardProgress.findUnique({
129
+ where: {
130
+ userId_flashcardId: {
131
+ userId,
132
+ flashcardId,
133
+ },
134
+ },
135
+ });
136
+ // Calculate new consecutive incorrect count
137
+ const newConsecutiveIncorrect = isCorrect
138
+ ? 0
139
+ : (existingProgress?.timesIncorrectConsecutive || 0) + 1;
140
+ // Auto-infer confidence based on performance
141
+ const inferredConfidence = this.inferConfidence(isCorrect, newConsecutiveIncorrect, existingProgress?.timesStudied || 0);
142
+ // Use provided confidence or inferred
143
+ const finalConfidence = data.confidence || inferredConfidence;
144
+ const quality = this.confidenceToQuality(finalConfidence);
145
+ // Calculate total incorrect after this attempt
146
+ const totalIncorrect = (existingProgress?.timesIncorrect || 0) + (isCorrect ? 0 : 1);
147
+ const sm2Result = this.calculateSM2(quality, existingProgress?.easeFactor, existingProgress?.interval, existingProgress?.repetitions, newConsecutiveIncorrect, totalIncorrect);
148
+ // Calculate mastery level (0-100)
149
+ const totalAttempts = (existingProgress?.timesStudied || 0) + 1;
150
+ const totalCorrect = (existingProgress?.timesCorrect || 0) + (isCorrect ? 1 : 0);
151
+ const successRate = totalCorrect / totalAttempts;
152
+ // Mastery considers success rate, repetitions, and consecutive failures
153
+ const consecutivePenalty = Math.min(newConsecutiveIncorrect * 10, 30); // Max 30% penalty
154
+ const masteryLevel = Math.min(100, Math.max(0, Math.round((successRate * 70) + // 70% weight on success rate
155
+ (Math.min(sm2Result.repetitions, 10) / 10) * 30 - // 30% weight on repetitions
156
+ consecutivePenalty // Penalty for consecutive failures
157
+ )));
158
+ // Upsert progress
159
+ return this.db.flashcardProgress.upsert({
160
+ where: {
161
+ userId_flashcardId: {
162
+ userId,
163
+ flashcardId,
164
+ },
165
+ },
166
+ update: {
167
+ timesStudied: { increment: 1 },
168
+ timesCorrect: isCorrect ? { increment: 1 } : undefined,
169
+ timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
170
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
171
+ easeFactor: sm2Result.easeFactor,
172
+ interval: sm2Result.interval,
173
+ repetitions: sm2Result.repetitions,
174
+ masteryLevel,
175
+ lastStudiedAt: new Date(),
176
+ nextReviewAt: sm2Result.nextReviewAt,
177
+ },
178
+ create: {
179
+ userId,
180
+ flashcardId,
181
+ timesStudied: 1,
182
+ timesCorrect: isCorrect ? 1 : 0,
183
+ timesIncorrect: isCorrect ? 0 : 1,
184
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
185
+ easeFactor: sm2Result.easeFactor,
186
+ interval: sm2Result.interval,
187
+ repetitions: sm2Result.repetitions,
188
+ masteryLevel,
189
+ lastStudiedAt: new Date(),
190
+ nextReviewAt: sm2Result.nextReviewAt,
191
+ },
192
+ include: {
193
+ flashcard: true,
194
+ },
195
+ });
196
+ }
197
+ /**
198
+ * Get user's progress on all flashcards in a set
199
+ */
200
+ async getSetProgress(userId, artifactId) {
201
+ const flashcards = await this.db.flashcard.findMany({
202
+ where: { artifactId },
203
+ });
204
+ // Manually fetch progress for each flashcard
205
+ const flashcardsWithProgress = await Promise.all(flashcards.map(async (card) => {
206
+ const progress = await this.db.flashcardProgress.findUnique({
207
+ where: {
208
+ userId_flashcardId: {
209
+ userId,
210
+ flashcardId: card.id,
211
+ },
212
+ },
213
+ });
214
+ return {
215
+ flashcardId: card.id,
216
+ front: card.front,
217
+ back: card.back,
218
+ progress: progress || null,
219
+ };
220
+ }));
221
+ return flashcardsWithProgress;
222
+ }
223
+ /**
224
+ * Get flashcards due for review, non-studied flashcards, and flashcards with low mastery
225
+ */
226
+ async getDueFlashcards(userId, workspaceId) {
227
+ const now = new Date();
228
+ const LOW_MASTERY_THRESHOLD = 50; // Consider mastery < 50 as low
229
+ // Get all flashcards in the workspace
230
+ const allFlashcards = await this.db.flashcard.findMany({
231
+ where: {
232
+ artifact: {
233
+ workspaceId,
234
+ },
235
+ },
236
+ include: {
237
+ artifact: true,
238
+ progress: {
239
+ where: {
240
+ userId,
241
+ },
242
+ },
243
+ },
244
+ });
245
+ const TAKE_NUMBER = (allFlashcards.length > 10) ? 10 : allFlashcards.length;
246
+ // Get progress records for flashcards that are due or have low mastery
247
+ const progressRecords = await this.db.flashcardProgress.findMany({
248
+ where: {
249
+ userId,
250
+ OR: [
251
+ {
252
+ nextReviewAt: {
253
+ lte: now,
254
+ },
255
+ },
256
+ {
257
+ masteryLevel: {
258
+ lt: LOW_MASTERY_THRESHOLD,
259
+ },
260
+ },
261
+ {
262
+ timesStudied: {
263
+ lt: 3,
264
+ },
265
+ }
266
+ ],
267
+ flashcard: {
268
+ artifact: {
269
+ workspaceId,
270
+ },
271
+ },
272
+ },
273
+ include: {
274
+ flashcard: {
275
+ include: {
276
+ artifact: true,
277
+ },
278
+ },
279
+ },
280
+ take: TAKE_NUMBER,
281
+ });
282
+ // Build result array: include progress records and non-studied flashcards
283
+ const results = [];
284
+ // Add flashcards with progress (due or low mastery)
285
+ for (const progress of progressRecords) {
286
+ results.push(progress);
287
+ }
288
+ // Sort by priority: due first (by nextReviewAt), then low mastery, then non-studied
289
+ results.sort((a, b) => {
290
+ // Due flashcards first (nextReviewAt <= now)
291
+ const aIsDue = a.nextReviewAt && a.nextReviewAt <= now;
292
+ const bIsDue = b.nextReviewAt && b.nextReviewAt <= now;
293
+ if (aIsDue && !bIsDue)
294
+ return -1;
295
+ if (!aIsDue && bIsDue)
296
+ return 1;
297
+ // Among due flashcards, sort by nextReviewAt
298
+ if (aIsDue && bIsDue && a.nextReviewAt && b.nextReviewAt) {
299
+ return a.nextReviewAt.getTime() - b.nextReviewAt.getTime();
300
+ }
301
+ // Then low mastery (lower mastery first)
302
+ if (a.masteryLevel !== b.masteryLevel) {
303
+ return a.masteryLevel - b.masteryLevel;
304
+ }
305
+ // Finally, non-studied (timesStudied === 0)
306
+ if (a.timesStudied === 0 && b.timesStudied !== 0)
307
+ return -1;
308
+ if (a.timesStudied !== 0 && b.timesStudied === 0)
309
+ return 1;
310
+ return 0;
311
+ });
312
+ return results.map((progress) => progress.flashcard);
313
+ }
314
+ /**
315
+ * Get user statistics for a flashcard set
316
+ */
317
+ async getSetStatistics(userId, artifactId) {
318
+ const progress = await this.db.flashcardProgress.findMany({
319
+ where: {
320
+ userId,
321
+ flashcard: {
322
+ artifactId,
323
+ },
324
+ },
325
+ });
326
+ const totalCards = await this.db.flashcard.count({
327
+ where: { artifactId },
328
+ });
329
+ const studiedCards = progress.length;
330
+ const masteredCards = progress.filter((p) => p.masteryLevel >= 80).length;
331
+ const dueForReview = progress.filter((p) => p.nextReviewAt && p.nextReviewAt <= new Date()).length;
332
+ const averageMastery = progress.length > 0
333
+ ? progress.reduce((sum, p) => sum + p.masteryLevel, 0) / progress.length
334
+ : 0;
335
+ const totalCorrect = progress.reduce((sum, p) => sum + p.timesCorrect, 0);
336
+ const totalAttempts = progress.reduce((sum, p) => sum + p.timesStudied, 0);
337
+ const successRate = totalAttempts > 0 ? (totalCorrect / totalAttempts) * 100 : 0;
338
+ return {
339
+ totalCards,
340
+ studiedCards,
341
+ unstudiedCards: totalCards - studiedCards,
342
+ masteredCards,
343
+ dueForReview,
344
+ averageMastery: Math.round(averageMastery),
345
+ successRate: Math.round(successRate),
346
+ totalAttempts,
347
+ totalCorrect,
348
+ };
349
+ }
350
+ /**
351
+ * Reset progress for a flashcard
352
+ */
353
+ async resetProgress(userId, flashcardId) {
354
+ return this.db.flashcardProgress.deleteMany({
355
+ where: {
356
+ userId,
357
+ flashcardId,
358
+ },
359
+ });
360
+ }
361
+ /**
362
+ * Bulk record study session
363
+ */
364
+ async recordStudySession(data) {
365
+ const { userId, attempts } = data;
366
+ // Process attempts sequentially
367
+ const results = [];
368
+ for (const attempt of attempts) {
369
+ const result = await this.recordStudyAttempt({
370
+ userId,
371
+ ...attempt,
372
+ });
373
+ results.push(result);
374
+ }
375
+ return results;
376
+ }
377
+ }
378
+ /**
379
+ * Factory function
380
+ */
381
+ export function createFlashcardProgressService(db) {
382
+ return new FlashcardProgressService(db);
383
+ }