@umituz/react-native-gamification 1.2.0 → 1.3.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.
- package/LICENSE +5 -0
- package/README.md +5 -0
- package/package.json +8 -14
- package/src/components/AchievementCard.tsx +142 -0
- package/src/components/AchievementToast.tsx +122 -0
- package/src/components/LevelProgress.tsx +129 -0
- package/src/components/PointsBadge.tsx +60 -0
- package/src/components/StreakDisplay.tsx +119 -0
- package/src/components/index.ts +10 -0
- package/src/hooks/useGamification.ts +91 -0
- package/src/index.ts +35 -111
- package/src/store/gamificationStore.ts +167 -0
- package/src/types/index.ts +103 -0
- package/src/utils/calculations.ts +85 -0
- package/src/domain/entities/Achievement.ts +0 -98
- package/src/domain/entities/Leaderboard.ts +0 -101
- package/src/domain/entities/Level.ts +0 -40
- package/src/domain/entities/Point.ts +0 -40
- package/src/domain/entities/Progress.ts +0 -43
- package/src/domain/entities/Reward.ts +0 -131
- package/src/domain/entities/Streak.ts +0 -154
- package/src/domain/repositories/IGamificationRepository.ts +0 -235
- package/src/infrastructure/repositories/StorageGamificationRepository.ts +0 -734
- package/src/infrastructure/storage/GamificationStore.ts +0 -350
- package/src/presentation/hooks/useAchievements.ts +0 -37
- package/src/presentation/hooks/useGamification.ts +0 -93
- package/src/presentation/hooks/useLevel.ts +0 -39
- package/src/presentation/hooks/usePoints.ts +0 -36
- package/src/presentation/hooks/useProgress.ts +0 -33
- package/src/presentation/hooks/useRewards.ts +0 -43
- package/src/presentation/hooks/useStreaks.ts +0 -36
|
@@ -1,734 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storage Gamification Repository Implementation
|
|
3
|
-
*
|
|
4
|
-
* Default implementation using @umituz/react-native-storage
|
|
5
|
-
* App-specific implementations can extend or replace this
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { storageRepository } from "@umituz/react-native-storage";
|
|
9
|
-
import { unwrap } from "@umituz/react-native-storage";
|
|
10
|
-
import type {
|
|
11
|
-
IGamificationRepository,
|
|
12
|
-
GamificationResult,
|
|
13
|
-
GamificationError,
|
|
14
|
-
} from "../../domain/repositories/IGamificationRepository";
|
|
15
|
-
import type { Achievement } from "../../domain/entities/Achievement";
|
|
16
|
-
import type { PointBalance, PointTransaction } from "../../domain/entities/Point";
|
|
17
|
-
import type { Level, LevelProgress } from "../../domain/entities/Level";
|
|
18
|
-
import type { Streak } from "../../domain/entities/Streak";
|
|
19
|
-
import type { Leaderboard, LeaderboardEntry, LeaderboardRanking } from "../../domain/entities/Leaderboard";
|
|
20
|
-
import type { Reward, RewardClaim } from "../../domain/entities/Reward";
|
|
21
|
-
import type { Progress, ProgressUpdate } from "../../domain/entities/Progress";
|
|
22
|
-
|
|
23
|
-
const STORAGE_KEYS = {
|
|
24
|
-
ACHIEVEMENTS: (userId: string) => `@gamification:achievements:${userId}`,
|
|
25
|
-
POINTS: (userId: string) => `@gamification:points:${userId}`,
|
|
26
|
-
POINT_TRANSACTIONS: (userId: string) => `@gamification:point_transactions:${userId}`,
|
|
27
|
-
LEVEL: (userId: string) => `@gamification:level:${userId}`,
|
|
28
|
-
STREAKS: (userId: string) => `@gamification:streaks:${userId}`,
|
|
29
|
-
LEADERBOARDS: (id: string) => `@gamification:leaderboards:${id}`,
|
|
30
|
-
REWARDS: (userId: string) => `@gamification:rewards:${userId}`,
|
|
31
|
-
PROGRESS: (userId: string) => `@gamification:progress:${userId}`,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
class StorageGamificationRepository implements IGamificationRepository {
|
|
35
|
-
private createError(
|
|
36
|
-
name: string,
|
|
37
|
-
message: string,
|
|
38
|
-
code: GamificationError["code"],
|
|
39
|
-
): GamificationError {
|
|
40
|
-
return { name, message, code };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private async loadJson<T>(key: string, defaultValue: T): Promise<GamificationResult<T>> {
|
|
44
|
-
try {
|
|
45
|
-
const result = await storageRepository.getString(key, JSON.stringify(defaultValue));
|
|
46
|
-
const data = unwrap(result, JSON.stringify(defaultValue));
|
|
47
|
-
return {
|
|
48
|
-
success: true,
|
|
49
|
-
data: JSON.parse(data) as T,
|
|
50
|
-
};
|
|
51
|
-
} catch (error) {
|
|
52
|
-
return {
|
|
53
|
-
success: false,
|
|
54
|
-
error: this.createError(
|
|
55
|
-
"LoadError",
|
|
56
|
-
error instanceof Error ? error.message : "Failed to load data",
|
|
57
|
-
"LOAD_FAILED",
|
|
58
|
-
),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private async saveJson<T>(key: string, data: T): Promise<GamificationResult<void>> {
|
|
64
|
-
try {
|
|
65
|
-
const result = await storageRepository.setString(key, JSON.stringify(data));
|
|
66
|
-
if (result.success) {
|
|
67
|
-
return { success: true, data: undefined };
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
success: false,
|
|
71
|
-
error: this.createError("SaveError", "Failed to save data", "SAVE_FAILED"),
|
|
72
|
-
};
|
|
73
|
-
} catch (error) {
|
|
74
|
-
return {
|
|
75
|
-
success: false,
|
|
76
|
-
error: this.createError(
|
|
77
|
-
"SaveError",
|
|
78
|
-
error instanceof Error ? error.message : "Failed to save data",
|
|
79
|
-
"SAVE_FAILED",
|
|
80
|
-
),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// =============================================================================
|
|
86
|
-
// ACHIEVEMENTS
|
|
87
|
-
// =============================================================================
|
|
88
|
-
|
|
89
|
-
async loadAchievements(userId: string): Promise<GamificationResult<Achievement[]>> {
|
|
90
|
-
return this.loadJson<Achievement[]>(STORAGE_KEYS.ACHIEVEMENTS(userId), []);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async saveAchievements(achievements: Achievement[]): Promise<GamificationResult<void>> {
|
|
94
|
-
if (achievements.length === 0) {
|
|
95
|
-
return { success: true, data: undefined };
|
|
96
|
-
}
|
|
97
|
-
const userId = achievements[0].userId;
|
|
98
|
-
return this.saveJson(STORAGE_KEYS.ACHIEVEMENTS(userId), achievements);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async getAchievementById(
|
|
102
|
-
userId: string,
|
|
103
|
-
achievementId: string,
|
|
104
|
-
): Promise<GamificationResult<Achievement>> {
|
|
105
|
-
const result = await this.loadAchievements(userId);
|
|
106
|
-
if (!result.success) {
|
|
107
|
-
return result;
|
|
108
|
-
}
|
|
109
|
-
const achievement = result.data.find((a) => a.id === achievementId);
|
|
110
|
-
if (!achievement) {
|
|
111
|
-
return {
|
|
112
|
-
success: false,
|
|
113
|
-
error: this.createError(
|
|
114
|
-
"NotFoundError",
|
|
115
|
-
`Achievement ${achievementId} not found`,
|
|
116
|
-
"NOT_FOUND",
|
|
117
|
-
),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
return { success: true, data: achievement };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async updateAchievementProgress(
|
|
124
|
-
userId: string,
|
|
125
|
-
achievementId: string,
|
|
126
|
-
progress: number,
|
|
127
|
-
): Promise<GamificationResult<Achievement>> {
|
|
128
|
-
const result = await this.loadAchievements(userId);
|
|
129
|
-
if (!result.success) {
|
|
130
|
-
return result;
|
|
131
|
-
}
|
|
132
|
-
const achievements = result.data;
|
|
133
|
-
const index = achievements.findIndex((a) => a.id === achievementId);
|
|
134
|
-
if (index === -1) {
|
|
135
|
-
return {
|
|
136
|
-
success: false,
|
|
137
|
-
error: this.createError(
|
|
138
|
-
"NotFoundError",
|
|
139
|
-
`Achievement ${achievementId} not found`,
|
|
140
|
-
"NOT_FOUND",
|
|
141
|
-
),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
achievements[index] = {
|
|
145
|
-
...achievements[index],
|
|
146
|
-
progress,
|
|
147
|
-
updatedDate: new Date().toISOString(),
|
|
148
|
-
};
|
|
149
|
-
const saveResult = await this.saveAchievements(achievements);
|
|
150
|
-
if (!saveResult.success) {
|
|
151
|
-
return saveResult;
|
|
152
|
-
}
|
|
153
|
-
return { success: true, data: achievements[index] };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async unlockAchievement(
|
|
157
|
-
userId: string,
|
|
158
|
-
achievementId: string,
|
|
159
|
-
): Promise<GamificationResult<Achievement>> {
|
|
160
|
-
const result = await this.loadAchievements(userId);
|
|
161
|
-
if (!result.success) {
|
|
162
|
-
return result;
|
|
163
|
-
}
|
|
164
|
-
const achievements = result.data;
|
|
165
|
-
const index = achievements.findIndex((a) => a.id === achievementId);
|
|
166
|
-
if (index === -1) {
|
|
167
|
-
return {
|
|
168
|
-
success: false,
|
|
169
|
-
error: this.createError(
|
|
170
|
-
"NotFoundError",
|
|
171
|
-
`Achievement ${achievementId} not found`,
|
|
172
|
-
"NOT_FOUND",
|
|
173
|
-
),
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
achievements[index] = {
|
|
177
|
-
...achievements[index],
|
|
178
|
-
unlocked: true,
|
|
179
|
-
unlockedDate: new Date().toISOString(),
|
|
180
|
-
progress: achievements[index].requirement,
|
|
181
|
-
updatedDate: new Date().toISOString(),
|
|
182
|
-
};
|
|
183
|
-
const saveResult = await this.saveAchievements(achievements);
|
|
184
|
-
if (!saveResult.success) {
|
|
185
|
-
return saveResult;
|
|
186
|
-
}
|
|
187
|
-
return { success: true, data: achievements[index] };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// =============================================================================
|
|
191
|
-
// POINTS
|
|
192
|
-
// =============================================================================
|
|
193
|
-
|
|
194
|
-
async loadPointBalance(userId: string): Promise<GamificationResult<PointBalance>> {
|
|
195
|
-
const transactionsResult = await this.loadPointTransactions(userId);
|
|
196
|
-
if (!transactionsResult.success) {
|
|
197
|
-
return {
|
|
198
|
-
success: false,
|
|
199
|
-
error: transactionsResult.error,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
const transactions = transactionsResult.data;
|
|
203
|
-
const total = transactions.reduce((sum, t) => sum + t.amount, 0);
|
|
204
|
-
const byCategory: Record<string, number> = {};
|
|
205
|
-
transactions.forEach((t) => {
|
|
206
|
-
if (t.category) {
|
|
207
|
-
byCategory[t.category] = (byCategory[t.category] || 0) + t.amount;
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
return {
|
|
211
|
-
success: true,
|
|
212
|
-
data: {
|
|
213
|
-
userId,
|
|
214
|
-
total,
|
|
215
|
-
byCategory,
|
|
216
|
-
lastUpdated: transactions[0]?.createdDate || new Date().toISOString(),
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async loadPointTransactions(
|
|
222
|
-
userId: string,
|
|
223
|
-
limit?: number,
|
|
224
|
-
): Promise<GamificationResult<PointTransaction[]>> {
|
|
225
|
-
const result = await this.loadJson<PointTransaction[]>(
|
|
226
|
-
STORAGE_KEYS.POINT_TRANSACTIONS(userId),
|
|
227
|
-
[],
|
|
228
|
-
);
|
|
229
|
-
if (!result.success) {
|
|
230
|
-
return result;
|
|
231
|
-
}
|
|
232
|
-
let transactions = result.data;
|
|
233
|
-
if (limit) {
|
|
234
|
-
transactions = transactions.slice(0, limit);
|
|
235
|
-
}
|
|
236
|
-
return { success: true, data: transactions };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async addPoints(
|
|
240
|
-
userId: string,
|
|
241
|
-
amount: number,
|
|
242
|
-
source: string,
|
|
243
|
-
sourceId?: string,
|
|
244
|
-
category?: string,
|
|
245
|
-
description?: string,
|
|
246
|
-
): Promise<GamificationResult<PointTransaction>> {
|
|
247
|
-
const balanceResult = await this.loadPointBalance(userId);
|
|
248
|
-
if (!balanceResult.success) {
|
|
249
|
-
return {
|
|
250
|
-
success: false,
|
|
251
|
-
error: balanceResult.error,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
const balance = balanceResult.data.total;
|
|
255
|
-
const transaction: PointTransaction = {
|
|
256
|
-
id: `${Date.now()}-${Math.random()}`,
|
|
257
|
-
userId,
|
|
258
|
-
amount,
|
|
259
|
-
source,
|
|
260
|
-
sourceId,
|
|
261
|
-
category,
|
|
262
|
-
description,
|
|
263
|
-
balance: balance + amount,
|
|
264
|
-
createdDate: new Date().toISOString(),
|
|
265
|
-
};
|
|
266
|
-
const transactionsResult = await this.loadPointTransactions(userId);
|
|
267
|
-
if (!transactionsResult.success) {
|
|
268
|
-
return {
|
|
269
|
-
success: false,
|
|
270
|
-
error: transactionsResult.error,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
const transactions = [transaction, ...transactionsResult.data];
|
|
274
|
-
const saveResult = await this.saveJson(
|
|
275
|
-
STORAGE_KEYS.POINT_TRANSACTIONS(userId),
|
|
276
|
-
transactions,
|
|
277
|
-
);
|
|
278
|
-
if (!saveResult.success) {
|
|
279
|
-
return {
|
|
280
|
-
success: false,
|
|
281
|
-
error: saveResult.error,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
return { success: true, data: transaction };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async deductPoints(
|
|
288
|
-
userId: string,
|
|
289
|
-
amount: number,
|
|
290
|
-
source: string,
|
|
291
|
-
sourceId?: string,
|
|
292
|
-
description?: string,
|
|
293
|
-
): Promise<GamificationResult<PointTransaction>> {
|
|
294
|
-
return this.addPoints(userId, -amount, source, sourceId, undefined, description);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// =============================================================================
|
|
298
|
-
// LEVELS
|
|
299
|
-
// =============================================================================
|
|
300
|
-
|
|
301
|
-
async loadLevel(userId: string): Promise<GamificationResult<Level>> {
|
|
302
|
-
return this.loadJson<Level>(STORAGE_KEYS.LEVEL(userId), {
|
|
303
|
-
id: `${userId}-level`,
|
|
304
|
-
userId,
|
|
305
|
-
currentLevel: 1,
|
|
306
|
-
currentExperience: 0,
|
|
307
|
-
totalExperience: 0,
|
|
308
|
-
experienceToNextLevel: 100,
|
|
309
|
-
levelProgress: 0,
|
|
310
|
-
createdDate: new Date().toISOString(),
|
|
311
|
-
updatedDate: new Date().toISOString(),
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async saveLevel(level: Level): Promise<GamificationResult<void>> {
|
|
316
|
-
return this.saveJson(STORAGE_KEYS.LEVEL(level.userId), level);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async addExperience(
|
|
320
|
-
userId: string,
|
|
321
|
-
amount: number,
|
|
322
|
-
source?: string,
|
|
323
|
-
): Promise<GamificationResult<LevelProgress>> {
|
|
324
|
-
const levelResult = await this.loadLevel(userId);
|
|
325
|
-
if (!levelResult.success) {
|
|
326
|
-
return {
|
|
327
|
-
success: false,
|
|
328
|
-
error: levelResult.error,
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
const level = levelResult.data;
|
|
332
|
-
const totalExperience = level.totalExperience + amount;
|
|
333
|
-
// Simple level calculation: 100 XP per level
|
|
334
|
-
const newLevel = Math.floor(totalExperience / 100) + 1;
|
|
335
|
-
const currentExperience = totalExperience % 100;
|
|
336
|
-
const experienceToNextLevel = 100 - currentExperience;
|
|
337
|
-
const levelProgress = (currentExperience / 100) * 100;
|
|
338
|
-
const updatedLevel: Level = {
|
|
339
|
-
...level,
|
|
340
|
-
currentLevel: newLevel,
|
|
341
|
-
currentExperience,
|
|
342
|
-
totalExperience,
|
|
343
|
-
experienceToNextLevel,
|
|
344
|
-
levelProgress,
|
|
345
|
-
updatedDate: new Date().toISOString(),
|
|
346
|
-
};
|
|
347
|
-
const saveResult = await this.saveLevel(updatedLevel);
|
|
348
|
-
if (!saveResult.success) {
|
|
349
|
-
return {
|
|
350
|
-
success: false,
|
|
351
|
-
error: saveResult.error,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
return {
|
|
355
|
-
success: true,
|
|
356
|
-
data: {
|
|
357
|
-
userId,
|
|
358
|
-
currentLevel: newLevel,
|
|
359
|
-
currentExperience,
|
|
360
|
-
totalExperience,
|
|
361
|
-
experienceToNextLevel,
|
|
362
|
-
levelProgress,
|
|
363
|
-
canLevelUp: newLevel > level.currentLevel,
|
|
364
|
-
},
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// =============================================================================
|
|
369
|
-
// STREAKS
|
|
370
|
-
// =============================================================================
|
|
371
|
-
|
|
372
|
-
async loadStreaks(userId: string): Promise<GamificationResult<Streak[]>> {
|
|
373
|
-
return this.loadJson<Streak[]>(STORAGE_KEYS.STREAKS(userId), []);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
async loadStreakByType(userId: string, type: string): Promise<GamificationResult<Streak>> {
|
|
377
|
-
const result = await this.loadStreaks(userId);
|
|
378
|
-
if (!result.success) {
|
|
379
|
-
return result;
|
|
380
|
-
}
|
|
381
|
-
const streak = result.data.find((s) => s.type === type);
|
|
382
|
-
if (!streak) {
|
|
383
|
-
return {
|
|
384
|
-
success: false,
|
|
385
|
-
error: this.createError(
|
|
386
|
-
"NotFoundError",
|
|
387
|
-
`Streak ${type} not found`,
|
|
388
|
-
"NOT_FOUND",
|
|
389
|
-
),
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
return { success: true, data: streak };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
async saveStreak(streak: Streak): Promise<GamificationResult<void>> {
|
|
396
|
-
const result = await this.loadStreaks(streak.userId);
|
|
397
|
-
if (!result.success) {
|
|
398
|
-
return result;
|
|
399
|
-
}
|
|
400
|
-
const streaks = result.data;
|
|
401
|
-
const index = streaks.findIndex((s) => s.id === streak.id);
|
|
402
|
-
if (index === -1) {
|
|
403
|
-
streaks.push(streak);
|
|
404
|
-
} else {
|
|
405
|
-
streaks[index] = streak;
|
|
406
|
-
}
|
|
407
|
-
return this.saveJson(STORAGE_KEYS.STREAKS(streak.userId), streaks);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async updateStreakActivity(
|
|
411
|
-
userId: string,
|
|
412
|
-
type: string,
|
|
413
|
-
activityDate: string,
|
|
414
|
-
): Promise<GamificationResult<Streak>> {
|
|
415
|
-
const result = await this.loadStreakByType(userId, type);
|
|
416
|
-
if (!result.success) {
|
|
417
|
-
// Create new streak if not found
|
|
418
|
-
const newStreak: Streak = {
|
|
419
|
-
id: `${userId}-${type}-${Date.now()}`,
|
|
420
|
-
userId,
|
|
421
|
-
type,
|
|
422
|
-
currentStreak: 1,
|
|
423
|
-
longestStreak: 1,
|
|
424
|
-
lastActivityDate: activityDate,
|
|
425
|
-
isActive: true,
|
|
426
|
-
createdDate: new Date().toISOString(),
|
|
427
|
-
updatedDate: new Date().toISOString(),
|
|
428
|
-
};
|
|
429
|
-
const saveResult = await this.saveStreak(newStreak);
|
|
430
|
-
if (!saveResult.success) {
|
|
431
|
-
return {
|
|
432
|
-
success: false,
|
|
433
|
-
error: saveResult.error,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
return { success: true, data: newStreak };
|
|
437
|
-
}
|
|
438
|
-
const streak = result.data;
|
|
439
|
-
const lastDate = new Date(streak.lastActivityDate);
|
|
440
|
-
const currentDate = new Date(activityDate);
|
|
441
|
-
const daysDiff = Math.floor(
|
|
442
|
-
(currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24),
|
|
443
|
-
);
|
|
444
|
-
if (daysDiff === 1) {
|
|
445
|
-
// Continue streak
|
|
446
|
-
streak.currentStreak += 1;
|
|
447
|
-
streak.longestStreak = Math.max(streak.longestStreak, streak.currentStreak);
|
|
448
|
-
streak.isActive = true;
|
|
449
|
-
} else if (daysDiff > 1) {
|
|
450
|
-
// Reset streak
|
|
451
|
-
streak.currentStreak = 1;
|
|
452
|
-
streak.isActive = true;
|
|
453
|
-
}
|
|
454
|
-
streak.lastActivityDate = activityDate;
|
|
455
|
-
streak.updatedDate = new Date().toISOString();
|
|
456
|
-
const saveResult = await this.saveStreak(streak);
|
|
457
|
-
if (!saveResult.success) {
|
|
458
|
-
return {
|
|
459
|
-
success: false,
|
|
460
|
-
error: saveResult.error,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
return { success: true, data: streak };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// =============================================================================
|
|
467
|
-
// LEADERBOARDS
|
|
468
|
-
// =============================================================================
|
|
469
|
-
|
|
470
|
-
async loadLeaderboard(
|
|
471
|
-
leaderboardId: string,
|
|
472
|
-
limit?: number,
|
|
473
|
-
offset?: number,
|
|
474
|
-
): Promise<GamificationResult<Leaderboard>> {
|
|
475
|
-
const result = await this.loadJson<Leaderboard>(
|
|
476
|
-
STORAGE_KEYS.LEADERBOARDS(leaderboardId),
|
|
477
|
-
{
|
|
478
|
-
id: leaderboardId,
|
|
479
|
-
name: leaderboardId,
|
|
480
|
-
metric: "score",
|
|
481
|
-
entries: [],
|
|
482
|
-
totalParticipants: 0,
|
|
483
|
-
lastUpdated: new Date().toISOString(),
|
|
484
|
-
},
|
|
485
|
-
);
|
|
486
|
-
if (!result.success) {
|
|
487
|
-
return result;
|
|
488
|
-
}
|
|
489
|
-
let entries = result.data.entries;
|
|
490
|
-
if (offset !== undefined) {
|
|
491
|
-
entries = entries.slice(offset);
|
|
492
|
-
}
|
|
493
|
-
if (limit !== undefined) {
|
|
494
|
-
entries = entries.slice(0, limit);
|
|
495
|
-
}
|
|
496
|
-
return {
|
|
497
|
-
success: true,
|
|
498
|
-
data: {
|
|
499
|
-
...result.data,
|
|
500
|
-
entries,
|
|
501
|
-
},
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
async getUserRanking(
|
|
506
|
-
userId: string,
|
|
507
|
-
leaderboardId: string,
|
|
508
|
-
): Promise<GamificationResult<LeaderboardRanking>> {
|
|
509
|
-
const result = await this.loadLeaderboard(leaderboardId);
|
|
510
|
-
if (!result.success) {
|
|
511
|
-
return {
|
|
512
|
-
success: false,
|
|
513
|
-
error: result.error,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
const entry = result.data.entries.find((e) => e.userId === userId);
|
|
517
|
-
if (!entry) {
|
|
518
|
-
return {
|
|
519
|
-
success: false,
|
|
520
|
-
error: this.createError(
|
|
521
|
-
"NotFoundError",
|
|
522
|
-
`User ${userId} not found in leaderboard`,
|
|
523
|
-
"NOT_FOUND",
|
|
524
|
-
),
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
const totalParticipants = result.data.totalParticipants;
|
|
528
|
-
const percentile = ((totalParticipants - entry.rank) / totalParticipants) * 100;
|
|
529
|
-
return {
|
|
530
|
-
success: true,
|
|
531
|
-
data: {
|
|
532
|
-
userId,
|
|
533
|
-
rank: entry.rank,
|
|
534
|
-
score: entry.score,
|
|
535
|
-
percentile,
|
|
536
|
-
aboveUsers: entry.rank - 1,
|
|
537
|
-
belowUsers: totalParticipants - entry.rank,
|
|
538
|
-
},
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
async updateLeaderboardEntry(
|
|
543
|
-
entry: LeaderboardEntry,
|
|
544
|
-
): Promise<GamificationResult<LeaderboardEntry>> {
|
|
545
|
-
const result = await this.loadLeaderboard(entry.leaderboardId);
|
|
546
|
-
if (!result.success) {
|
|
547
|
-
return {
|
|
548
|
-
success: false,
|
|
549
|
-
error: result.error,
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
const leaderboard = result.data;
|
|
553
|
-
const index = leaderboard.entries.findIndex((e) => e.id === entry.id);
|
|
554
|
-
if (index === -1) {
|
|
555
|
-
leaderboard.entries.push(entry);
|
|
556
|
-
} else {
|
|
557
|
-
leaderboard.entries[index] = entry;
|
|
558
|
-
}
|
|
559
|
-
// Sort by score descending
|
|
560
|
-
leaderboard.entries.sort((a, b) => b.score - a.score);
|
|
561
|
-
// Update ranks
|
|
562
|
-
leaderboard.entries.forEach((e, i) => {
|
|
563
|
-
e.rank = i + 1;
|
|
564
|
-
});
|
|
565
|
-
leaderboard.totalParticipants = leaderboard.entries.length;
|
|
566
|
-
leaderboard.lastUpdated = new Date().toISOString();
|
|
567
|
-
const saveResult = await this.saveJson(
|
|
568
|
-
STORAGE_KEYS.LEADERBOARDS(entry.leaderboardId),
|
|
569
|
-
leaderboard,
|
|
570
|
-
);
|
|
571
|
-
if (!saveResult.success) {
|
|
572
|
-
return {
|
|
573
|
-
success: false,
|
|
574
|
-
error: saveResult.error,
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
return { success: true, data: entry };
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// =============================================================================
|
|
581
|
-
// REWARDS
|
|
582
|
-
// =============================================================================
|
|
583
|
-
|
|
584
|
-
async loadRewards(userId: string): Promise<GamificationResult<Reward[]>> {
|
|
585
|
-
return this.loadJson<Reward[]>(STORAGE_KEYS.REWARDS(userId), []);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
async saveReward(reward: Reward): Promise<GamificationResult<void>> {
|
|
589
|
-
const result = await this.loadRewards(reward.userId);
|
|
590
|
-
if (!result.success) {
|
|
591
|
-
return result;
|
|
592
|
-
}
|
|
593
|
-
const rewards = result.data;
|
|
594
|
-
const index = rewards.findIndex((r) => r.id === reward.id);
|
|
595
|
-
if (index === -1) {
|
|
596
|
-
rewards.push(reward);
|
|
597
|
-
} else {
|
|
598
|
-
rewards[index] = reward;
|
|
599
|
-
}
|
|
600
|
-
return this.saveJson(STORAGE_KEYS.REWARDS(reward.userId), rewards);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
async claimReward(
|
|
604
|
-
userId: string,
|
|
605
|
-
rewardId: string,
|
|
606
|
-
): Promise<GamificationResult<RewardClaim>> {
|
|
607
|
-
const result = await this.loadRewards(userId);
|
|
608
|
-
if (!result.success) {
|
|
609
|
-
return {
|
|
610
|
-
success: false,
|
|
611
|
-
error: result.error,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
const reward = result.data.find((r) => r.id === rewardId);
|
|
615
|
-
if (!reward) {
|
|
616
|
-
return {
|
|
617
|
-
success: false,
|
|
618
|
-
error: this.createError(
|
|
619
|
-
"NotFoundError",
|
|
620
|
-
`Reward ${rewardId} not found`,
|
|
621
|
-
"NOT_FOUND",
|
|
622
|
-
),
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
if (reward.claimed) {
|
|
626
|
-
return {
|
|
627
|
-
success: false,
|
|
628
|
-
error: this.createError(
|
|
629
|
-
"InvalidDataError",
|
|
630
|
-
"Reward already claimed",
|
|
631
|
-
"INVALID_DATA",
|
|
632
|
-
),
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
reward.claimed = true;
|
|
636
|
-
reward.claimedDate = new Date().toISOString();
|
|
637
|
-
reward.updatedDate = new Date().toISOString();
|
|
638
|
-
const saveResult = await this.saveReward(reward);
|
|
639
|
-
if (!saveResult.success) {
|
|
640
|
-
return {
|
|
641
|
-
success: false,
|
|
642
|
-
error: saveResult.error,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
const claim: RewardClaim = {
|
|
646
|
-
id: `${Date.now()}-${Math.random()}`,
|
|
647
|
-
userId,
|
|
648
|
-
rewardId,
|
|
649
|
-
pointsSpent: reward.pointsCost,
|
|
650
|
-
claimedDate: reward.claimedDate,
|
|
651
|
-
};
|
|
652
|
-
return { success: true, data: claim };
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// =============================================================================
|
|
656
|
-
// PROGRESS
|
|
657
|
-
// =============================================================================
|
|
658
|
-
|
|
659
|
-
async loadProgress(
|
|
660
|
-
userId: string,
|
|
661
|
-
metric?: string,
|
|
662
|
-
): Promise<GamificationResult<Progress[]>> {
|
|
663
|
-
const result = await this.loadJson<Progress[]>(STORAGE_KEYS.PROGRESS(userId), []);
|
|
664
|
-
if (!result.success) {
|
|
665
|
-
return result;
|
|
666
|
-
}
|
|
667
|
-
if (metric) {
|
|
668
|
-
return {
|
|
669
|
-
success: true,
|
|
670
|
-
data: result.data.filter((p) => p.metric === metric),
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
return result;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
async saveProgress(progress: Progress): Promise<GamificationResult<void>> {
|
|
677
|
-
const result = await this.loadProgress(progress.userId);
|
|
678
|
-
if (!result.success) {
|
|
679
|
-
return result;
|
|
680
|
-
}
|
|
681
|
-
const progresses = result.data;
|
|
682
|
-
const index = progresses.findIndex((p) => p.id === progress.id);
|
|
683
|
-
if (index === -1) {
|
|
684
|
-
progresses.push(progress);
|
|
685
|
-
} else {
|
|
686
|
-
progresses[index] = progress;
|
|
687
|
-
}
|
|
688
|
-
return this.saveJson(STORAGE_KEYS.PROGRESS(progress.userId), progresses);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
async updateProgress(update: ProgressUpdate): Promise<GamificationResult<Progress>> {
|
|
692
|
-
const result = await this.loadProgress(update.userId, update.metric);
|
|
693
|
-
if (!result.success) {
|
|
694
|
-
return {
|
|
695
|
-
success: false,
|
|
696
|
-
error: result.error,
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
let progress = result.data[0];
|
|
700
|
-
if (!progress) {
|
|
701
|
-
// Create new progress
|
|
702
|
-
progress = {
|
|
703
|
-
id: `${update.userId}-${update.metric}-${Date.now()}`,
|
|
704
|
-
userId: update.userId,
|
|
705
|
-
metric: update.metric,
|
|
706
|
-
currentValue: update.increment,
|
|
707
|
-
progress: 0,
|
|
708
|
-
category: update.category,
|
|
709
|
-
period: update.period,
|
|
710
|
-
createdDate: new Date().toISOString(),
|
|
711
|
-
updatedDate: new Date().toISOString(),
|
|
712
|
-
};
|
|
713
|
-
} else {
|
|
714
|
-
progress.currentValue += update.increment;
|
|
715
|
-
if (progress.targetValue) {
|
|
716
|
-
progress.progress = Math.min(100, (progress.currentValue / progress.targetValue) * 100);
|
|
717
|
-
}
|
|
718
|
-
progress.updatedDate = new Date().toISOString();
|
|
719
|
-
}
|
|
720
|
-
const saveResult = await this.saveProgress(progress);
|
|
721
|
-
if (!saveResult.success) {
|
|
722
|
-
return {
|
|
723
|
-
success: false,
|
|
724
|
-
error: saveResult.error,
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
return { success: true, data: progress };
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
export const storageGamificationRepository = new StorageGamificationRepository();
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|