Skip to content

Commit f34a2a8

Browse files
committed
feat: Add Engagement System with Streaks & Milestones
TASK 12 COMPLETE (30 minutes): ✅ Lightweight gamification for daily practice ✅ Streak tracking (current + longest) ✅ Total check-ins counter ✅ 7 milestone types with achievement system ✅ Auto check-in on app use ✅ Celebration modal for new milestones STREAK TRACKING: ✅ Daily check-in detection (YYYYMMDD comparison) ✅ Streak continuation logic (yesterday = continue, else reset to 1) ✅ Longest streak record keeping ✅ Total practice sessions counter ✅ Automatic streak breaks after missed days MILESTONE SYSTEM: ✅ Streak milestones: 3, 7, 30, 100 days ✅ Total milestones: 10, 50, 100 sessions ✅ Achievement timestamps ✅ Celebration state tracking ✅ Localized titles/descriptions (Vietnamese + English) ✅ Emoji rewards (🔥⭐🌟👑🌱🌿🌳) UI COMPONENTS: ✅ Floating streak badge (bottom-left) ✅ Fire emoji animation on active streaks ✅ Gradient design (orange→red for active, white for 0) ✅ Details modal with stats grid ✅ Milestone celebration popup with bounce animation ✅ Clean, minimal design STORAGE: ✅ IndexedDB v4 schema ✅ StreakData type with user_id keying ✅ Efficient caching layer ✅ Automatic check-in on load FILES ADDED: - services/streakService.ts: Core streak engine (268 LOC) - components/StreakBadge.tsx: UI components (193 LOC) FILES UPDATED: - types.ts: StreakData interface - src/views/MainView.tsx: Integrated StreakBadge ALIGNMENT WITH VISION 2.0: ✓ Light gamification (not over-the-top) ✓ Encourages daily practice (adherence) ✓ Positive reinforcement (milestones) ✓ Privacy-first (local storage) ✓ Bilingual support BUILD: ✓ 3275 modules, 17.17s TOTAL CODE: 461 LOC NEXT: Gratitude journaling (uses streak system for daily prompts)
1 parent 22f1d20 commit f34a2a8

4 files changed

Lines changed: 516 additions & 0 deletions

File tree

components/StreakBadge.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { Flame, Award, X } from 'lucide-react';
3+
import { StreakData } from '../types';
4+
import { StreakService } from '../services/streakService';
5+
6+
interface Props {
7+
language: 'vi' | 'en';
8+
}
9+
10+
export const StreakBadge: React.FC<Props> = ({ language }) => {
11+
const [streak, setStreak] = useState<StreakData | null>(null);
12+
const [showDetails, setShowDetails] = useState(false);
13+
const [newMilestone, setNewMilestone] = useState<StreakData['milestones'][0] | null>(null);
14+
15+
useEffect(() => {
16+
loadStreak();
17+
}, []);
18+
19+
const loadStreak = async () => {
20+
const data = await StreakService.getStreak();
21+
setStreak(data);
22+
23+
// Auto check-in on load
24+
const { isNewDay, newMilestones } = await StreakService.checkIn();
25+
26+
if (isNewDay) {
27+
// Reload to get updated data
28+
const updated = await StreakService.getStreak();
29+
setStreak(updated);
30+
31+
// Show milestone celebration if any
32+
if (newMilestones.length > 0) {
33+
setNewMilestone(newMilestones[0]);
34+
}
35+
}
36+
};
37+
38+
const handleCelebrate = async () => {
39+
if (newMilestone) {
40+
await StreakService.celebrateMilestone(newMilestone.type);
41+
setNewMilestone(null);
42+
}
43+
};
44+
45+
if (!streak) return null;
46+
47+
const text = language === 'vi' ? {
48+
streak: 'Chuỗi',
49+
days: 'ngày',
50+
longest: 'Dài nhất',
51+
total: 'Tổng số',
52+
sessions: 'buổi',
53+
milestones: 'Thành Tựu',
54+
close: 'Đóng',
55+
celebrate: 'Tuyệt vời!'
56+
} : {
57+
streak: 'Streak',
58+
days: 'days',
59+
longest: 'Longest',
60+
total: 'Total',
61+
sessions: 'sessions',
62+
milestones: 'Milestones',
63+
close: 'Close',
64+
celebrate: 'Awesome!'
65+
};
66+
67+
return (
68+
<>
69+
{/* Floating Badge */}
70+
<button
71+
onClick={() => setShowDetails(true)}
72+
className={`fixed bottom-36 left-4 z-40 flex items-center gap-2 px-3 py-2 rounded-full shadow-lg transition-all ${
73+
streak.current_streak > 0
74+
? 'bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white'
75+
: 'bg-white/90 backdrop-blur-md hover:bg-white text-stone-700 border border-stone-200'
76+
}`}
77+
aria-label="View streak"
78+
>
79+
<Flame size={18} className={streak.current_streak > 0 ? 'animate-pulse' : ''} />
80+
<span className="font-bold text-sm">{streak.current_streak}</span>
81+
</button>
82+
83+
{/* Details Modal */}
84+
{showDetails && (
85+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
86+
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full">
87+
{/* Header */}
88+
<div className="bg-gradient-to-r from-orange-500 to-red-500 text-white p-6 rounded-t-2xl">
89+
<div className="flex items-center justify-between">
90+
<div className="flex items-center gap-3">
91+
<Flame size={28} />
92+
<div>
93+
<h2 className="text-2xl font-bold">{text.streak}</h2>
94+
<p className="text-orange-100 text-sm">
95+
{language === 'vi' ? 'Thực hành đều đặn' : 'Consistent practice'}
96+
</p>
97+
</div>
98+
</div>
99+
<button
100+
onClick={() => setShowDetails(false)}
101+
className="p-2 rounded-full hover:bg-white/20 transition-colors"
102+
>
103+
<X size={20} />
104+
</button>
105+
</div>
106+
</div>
107+
108+
{/* Stats */}
109+
<div className="p-6 space-y-4">
110+
{/* Current Streak */}
111+
<div className="text-center p-4 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl border border-orange-200">
112+
<div className="text-5xl font-bold text-orange-600 mb-1">
113+
{streak.current_streak}
114+
</div>
115+
<div className="text-sm text-orange-700 font-medium">
116+
{text.days} {text.streak.toLowerCase()}
117+
</div>
118+
</div>
119+
120+
{/* Other Stats */}
121+
<div className="grid grid-cols-2 gap-3">
122+
<StatCard
123+
label={text.longest}
124+
value={streak.longest_streak}
125+
suffix={text.days}
126+
color="from-yellow-500 to-amber-500"
127+
/>
128+
<StatCard
129+
label={text.total}
130+
value={streak.total_check_ins}
131+
suffix={text.sessions}
132+
color="from-green-500 to-emerald-500"
133+
/>
134+
</div>
135+
136+
{/* Milestones */}
137+
{streak.milestones.length > 0 && (
138+
<div>
139+
<div className="flex items-center gap-2 mb-3">
140+
<Award size={16} className="text-purple-600" />
141+
<h3 className="font-semibold text-stone-800">{text.milestones}</h3>
142+
</div>
143+
<div className="flex flex-wrap gap-2">
144+
{streak.milestones.map((milestone, idx) => {
145+
const info = StreakService.getMilestoneInfo(milestone.type, language);
146+
return (
147+
<div
148+
key={idx}
149+
className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-full text-xs font-medium border border-purple-200 flex items-center gap-1"
150+
title={info.description}
151+
>
152+
<span>{info.emoji}</span>
153+
<span>{info.title}</span>
154+
</div>
155+
);
156+
})}
157+
</div>
158+
</div>
159+
)}
160+
</div>
161+
162+
{/* Footer */}
163+
<div className="border-t border-stone-200 p-4 bg-stone-50 rounded-b-2xl">
164+
<button
165+
onClick={() => setShowDetails(false)}
166+
className="w-full py-3 px-4 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white font-medium transition-all shadow-lg"
167+
>
168+
{text.close}
169+
</button>
170+
</div>
171+
</div>
172+
</div>
173+
)}
174+
175+
{/* Milestone Celebration */}
176+
{newMilestone && (
177+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 animate-[fadeIn_0.3s_ease-out]">
178+
<div className="bg-white rounded-2xl shadow-2xl max-w-sm w-full p-8 text-center transform animate-[scaleIn_0.5s_ease-out]">
179+
<div className="text-6xl mb-4 animate-bounce">
180+
{StreakService.getMilestoneInfo(newMilestone.type, language).emoji}
181+
</div>
182+
<h2 className="text-2xl font-bold text-stone-800 mb-2">
183+
{StreakService.getMilestoneInfo(newMilestone.type, language).title}
184+
</h2>
185+
<p className="text-stone-600 mb-6">
186+
{StreakService.getMilestoneInfo(newMilestone.type, language).description}
187+
</p>
188+
<button
189+
onClick={handleCelebrate}
190+
className="w-full py-3 px-4 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold transition-all shadow-lg"
191+
>
192+
{text.celebrate}
193+
</button>
194+
</div>
195+
</div>
196+
)}
197+
</>
198+
);
199+
};
200+
201+
interface StatCardProps {
202+
label: string;
203+
value: number;
204+
suffix: string;
205+
color: string;
206+
}
207+
208+
const StatCard: React.FC<StatCardProps> = ({ label, value, suffix, color }) => {
209+
return (
210+
<div className="p-3 bg-white rounded-lg border border-stone-200 shadow-sm">
211+
<div className="text-xs text-stone-500 mb-1">{label}</div>
212+
<div className={`text-2xl font-bold bg-gradient-to-r ${color} bg-clip-text text-transparent`}>
213+
{value}
214+
</div>
215+
<div className="text-xs text-stone-400">{suffix}</div>
216+
</div>
217+
);
218+
};

0 commit comments

Comments
 (0)