mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 11:41:08 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { create } from 'zustand';
|
|
import { supabase } from '../utils/supabase';
|
|
|
|
export interface DailyProgress {
|
|
date: string; // YYYY-MM-DD
|
|
cards_studied: number;
|
|
correct_answers: number;
|
|
total_time_minutes: number;
|
|
sessions_count: number;
|
|
new_cards: number;
|
|
reviewed_cards: number;
|
|
}
|
|
|
|
export interface StreakInfo {
|
|
current_streak: number;
|
|
longest_streak: number;
|
|
last_study_date: string;
|
|
total_study_days: number;
|
|
}
|
|
|
|
export interface DeckProgress {
|
|
deck_id: string;
|
|
deck_name: string;
|
|
total_cards: number;
|
|
mastered_cards: number; // ease_factor >= 2.5 && interval >= 21
|
|
learning_cards: number;
|
|
new_cards: number;
|
|
average_ease_factor: number;
|
|
completion_percentage: number;
|
|
}
|
|
|
|
export interface Statistics {
|
|
total_cards_studied: number;
|
|
total_study_time_minutes: number;
|
|
average_accuracy: number;
|
|
best_accuracy_day: string;
|
|
most_studied_day: string;
|
|
favorite_time_of_day: string;
|
|
}
|
|
|
|
interface ProgressState {
|
|
// Data
|
|
dailyProgress: Map<string, DailyProgress>; // date -> progress
|
|
streakInfo: StreakInfo | null;
|
|
deckProgress: DeckProgress[];
|
|
statistics: Statistics | null;
|
|
|
|
// UI State
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
selectedPeriod: 'week' | 'month' | 'year';
|
|
|
|
// Actions
|
|
fetchDailyProgress: (userId: string, startDate: Date, endDate: Date) => Promise<void>;
|
|
fetchStreakInfo: (userId: string) => Promise<void>;
|
|
fetchDeckProgress: (userId: string) => Promise<void>;
|
|
fetchStatistics: (userId: string) => Promise<void>;
|
|
calculateStreak: (sessions: any[]) => StreakInfo;
|
|
updateTodayProgress: (sessionData: any) => void;
|
|
|
|
// Utilities
|
|
getHeatmapData: () => { date: string; count: number }[];
|
|
getChartData: (type: 'accuracy' | 'cards' | 'time') => any[];
|
|
setSelectedPeriod: (period: 'week' | 'month' | 'year') => void;
|
|
clearError: () => void;
|
|
}
|
|
|
|
export const useProgressStore = create<ProgressState>((set, get) => ({
|
|
// Initial state
|
|
dailyProgress: new Map(),
|
|
streakInfo: null,
|
|
deckProgress: [],
|
|
statistics: null,
|
|
isLoading: false,
|
|
error: null,
|
|
selectedPeriod: 'week',
|
|
|
|
fetchDailyProgress: async (userId: string, startDate: Date, endDate: Date) => {
|
|
try {
|
|
set({ isLoading: true, error: null });
|
|
|
|
// Fetch study sessions in date range
|
|
const { data: sessions, error } = await supabase
|
|
.from('study_sessions')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.gte('started_at', startDate.toISOString())
|
|
.lte('started_at', endDate.toISOString())
|
|
.order('started_at', { ascending: true });
|
|
|
|
if (error) throw error;
|
|
|
|
// Group sessions by date
|
|
const progressMap = new Map<string, DailyProgress>();
|
|
|
|
sessions?.forEach((session) => {
|
|
const date = new Date(session.started_at).toISOString().split('T')[0];
|
|
const existing = progressMap.get(date) || {
|
|
date,
|
|
cards_studied: 0,
|
|
correct_answers: 0,
|
|
total_time_minutes: 0,
|
|
sessions_count: 0,
|
|
new_cards: 0,
|
|
reviewed_cards: 0,
|
|
};
|
|
|
|
const sessionDuration = session.ended_at
|
|
? (new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000
|
|
: 0;
|
|
|
|
progressMap.set(date, {
|
|
...existing,
|
|
cards_studied: existing.cards_studied + (session.completed_cards || 0),
|
|
correct_answers: existing.correct_answers + (session.correct_answers || 0),
|
|
total_time_minutes: existing.total_time_minutes + Math.round(sessionDuration),
|
|
sessions_count: existing.sessions_count + 1,
|
|
new_cards:
|
|
existing.new_cards + (session.mode === 'new' ? session.completed_cards || 0 : 0),
|
|
reviewed_cards:
|
|
existing.reviewed_cards +
|
|
(session.mode === 'review' ? session.completed_cards || 0 : 0),
|
|
});
|
|
});
|
|
|
|
set({ dailyProgress: progressMap });
|
|
|
|
// Also calculate streak from sessions
|
|
if (sessions && sessions.length > 0) {
|
|
const streakInfo = get().calculateStreak(sessions);
|
|
set({ streakInfo });
|
|
}
|
|
} catch (error: any) {
|
|
set({ error: error.message });
|
|
console.error('Error fetching daily progress:', error);
|
|
} finally {
|
|
set({ isLoading: false });
|
|
}
|
|
},
|
|
|
|
calculateStreak: (sessions: any[]) => {
|
|
if (!sessions || sessions.length === 0) {
|
|
return {
|
|
current_streak: 0,
|
|
longest_streak: 0,
|
|
last_study_date: '',
|
|
total_study_days: 0,
|
|
};
|
|
}
|
|
|
|
// Get unique study dates
|
|
const studyDates = new Set(
|
|
sessions.map((s) => new Date(s.started_at).toISOString().split('T')[0])
|
|
);
|
|
const sortedDates = Array.from(studyDates).sort();
|
|
|
|
// Calculate streaks
|
|
let currentStreak = 0;
|
|
let longestStreak = 0;
|
|
let tempStreak = 1;
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
|
|
|
// Check if studied today or yesterday for current streak
|
|
const lastStudyDate = sortedDates[sortedDates.length - 1];
|
|
if (lastStudyDate === today || lastStudyDate === yesterday) {
|
|
currentStreak = 1;
|
|
|
|
// Count backwards from last study date
|
|
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
|
const prevDate = new Date(sortedDates[i]);
|
|
const currDate = new Date(sortedDates[i + 1]);
|
|
const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000);
|
|
|
|
if (diffDays === 1) {
|
|
currentStreak++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate longest streak
|
|
for (let i = 1; i < sortedDates.length; i++) {
|
|
const prevDate = new Date(sortedDates[i - 1]);
|
|
const currDate = new Date(sortedDates[i]);
|
|
const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000);
|
|
|
|
if (diffDays === 1) {
|
|
tempStreak++;
|
|
} else {
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
tempStreak = 1;
|
|
}
|
|
}
|
|
longestStreak = Math.max(longestStreak, tempStreak, currentStreak);
|
|
|
|
return {
|
|
current_streak: currentStreak,
|
|
longest_streak: longestStreak,
|
|
last_study_date: lastStudyDate,
|
|
total_study_days: studyDates.size,
|
|
};
|
|
},
|
|
|
|
fetchStreakInfo: async (userId: string) => {
|
|
try {
|
|
// Fetch all study sessions for streak calculation
|
|
const { data: sessions, error } = await supabase
|
|
.from('study_sessions')
|
|
.select('started_at')
|
|
.eq('user_id', userId)
|
|
.order('started_at', { ascending: true });
|
|
|
|
if (error) throw error;
|
|
|
|
const streakInfo = get().calculateStreak(sessions || []);
|
|
set({ streakInfo });
|
|
} catch (error: any) {
|
|
console.error('Error fetching streak info:', error);
|
|
}
|
|
},
|
|
|
|
fetchDeckProgress: async (userId: string) => {
|
|
try {
|
|
set({ isLoading: true });
|
|
|
|
// Fetch all decks with card counts
|
|
const { data: decks, error: decksError } = await supabase
|
|
.from('decks')
|
|
.select('*, cards(count)')
|
|
.eq('user_id', userId);
|
|
|
|
if (decksError) throw decksError;
|
|
|
|
// Fetch card progress for all decks
|
|
const { data: cardProgress, error: progressError } = await supabase
|
|
.from('card_progress')
|
|
.select('*')
|
|
.eq('user_id', userId);
|
|
|
|
if (progressError) throw progressError;
|
|
|
|
// Calculate progress per deck
|
|
const deckProgressList: DeckProgress[] = [];
|
|
|
|
decks?.forEach((deck) => {
|
|
const deckCardProgress = cardProgress?.filter((cp) => cp.deck_id === deck.id) || [];
|
|
|
|
const mastered = deckCardProgress.filter(
|
|
(cp) => cp.ease_factor >= 2.5 && cp.interval >= 21
|
|
).length;
|
|
|
|
const learning = deckCardProgress.filter(
|
|
(cp) => cp.status === 'learning' || cp.status === 'relearning'
|
|
).length;
|
|
|
|
const newCards = deckCardProgress.filter((cp) => cp.status === 'new').length;
|
|
|
|
const avgEaseFactor =
|
|
deckCardProgress.length > 0
|
|
? deckCardProgress.reduce((sum, cp) => sum + cp.ease_factor, 0) /
|
|
deckCardProgress.length
|
|
: 2.5;
|
|
|
|
const totalCards = deck.cards?.[0]?.count || 0;
|
|
const studiedCards = deckCardProgress.length;
|
|
|
|
deckProgressList.push({
|
|
deck_id: deck.id,
|
|
deck_name: deck.title,
|
|
total_cards: totalCards,
|
|
mastered_cards: mastered,
|
|
learning_cards: learning,
|
|
new_cards: totalCards - studiedCards,
|
|
average_ease_factor: Math.round(avgEaseFactor * 100) / 100,
|
|
completion_percentage: totalCards > 0 ? Math.round((mastered / totalCards) * 100) : 0,
|
|
});
|
|
});
|
|
|
|
set({ deckProgress: deckProgressList });
|
|
} catch (error: any) {
|
|
console.error('Error fetching deck progress:', error);
|
|
} finally {
|
|
set({ isLoading: false });
|
|
}
|
|
},
|
|
|
|
fetchStatistics: async (userId: string) => {
|
|
try {
|
|
// Fetch all sessions for statistics
|
|
const { data: sessions, error } = await supabase
|
|
.from('study_sessions')
|
|
.select('*')
|
|
.eq('user_id', userId);
|
|
|
|
if (error) throw error;
|
|
if (!sessions || sessions.length === 0) return;
|
|
|
|
// Calculate statistics
|
|
let totalCards = 0;
|
|
let totalCorrect = 0;
|
|
let totalTime = 0;
|
|
let bestAccuracy = 0;
|
|
let bestAccuracyDay = '';
|
|
let mostCards = 0;
|
|
let mostStudiedDay = '';
|
|
|
|
const timeOfDayCount = new Map<number, number>();
|
|
|
|
sessions.forEach((session) => {
|
|
totalCards += session.completed_cards || 0;
|
|
totalCorrect += session.correct_answers || 0;
|
|
|
|
if (session.ended_at) {
|
|
const duration =
|
|
(new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000;
|
|
totalTime += duration;
|
|
}
|
|
|
|
// Track accuracy
|
|
if (session.completed_cards > 0) {
|
|
const accuracy = (session.correct_answers / session.completed_cards) * 100;
|
|
if (accuracy > bestAccuracy) {
|
|
bestAccuracy = accuracy;
|
|
bestAccuracyDay = new Date(session.started_at).toISOString().split('T')[0];
|
|
}
|
|
}
|
|
|
|
// Track most studied day
|
|
const date = new Date(session.started_at).toISOString().split('T')[0];
|
|
if (session.completed_cards > mostCards) {
|
|
mostCards = session.completed_cards;
|
|
mostStudiedDay = date;
|
|
}
|
|
|
|
// Track time of day
|
|
const hour = new Date(session.started_at).getHours();
|
|
timeOfDayCount.set(hour, (timeOfDayCount.get(hour) || 0) + 1);
|
|
});
|
|
|
|
// Find favorite time of day
|
|
let favoriteHour = 0;
|
|
let maxCount = 0;
|
|
timeOfDayCount.forEach((count, hour) => {
|
|
if (count > maxCount) {
|
|
maxCount = count;
|
|
favoriteHour = hour;
|
|
}
|
|
});
|
|
|
|
const favoriteTimeOfDay =
|
|
favoriteHour < 6
|
|
? 'Nacht (0-6 Uhr)'
|
|
: favoriteHour < 12
|
|
? 'Morgen (6-12 Uhr)'
|
|
: favoriteHour < 18
|
|
? 'Nachmittag (12-18 Uhr)'
|
|
: 'Abend (18-24 Uhr)';
|
|
|
|
set({
|
|
statistics: {
|
|
total_cards_studied: totalCards,
|
|
total_study_time_minutes: Math.round(totalTime),
|
|
average_accuracy: totalCards > 0 ? Math.round((totalCorrect / totalCards) * 100) : 0,
|
|
best_accuracy_day: bestAccuracyDay,
|
|
most_studied_day: mostStudiedDay,
|
|
favorite_time_of_day: favoriteTimeOfDay,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
console.error('Error fetching statistics:', error);
|
|
}
|
|
},
|
|
|
|
updateTodayProgress: (sessionData: any) => {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const dailyProgress = get().dailyProgress;
|
|
const existing = dailyProgress.get(today) || {
|
|
date: today,
|
|
cards_studied: 0,
|
|
correct_answers: 0,
|
|
total_time_minutes: 0,
|
|
sessions_count: 0,
|
|
new_cards: 0,
|
|
reviewed_cards: 0,
|
|
};
|
|
|
|
dailyProgress.set(today, {
|
|
...existing,
|
|
cards_studied: existing.cards_studied + sessionData.completed_cards,
|
|
correct_answers: existing.correct_answers + sessionData.correct_answers,
|
|
sessions_count: existing.sessions_count + 1,
|
|
});
|
|
|
|
set({ dailyProgress: new Map(dailyProgress) });
|
|
},
|
|
|
|
getHeatmapData: () => {
|
|
const dailyProgress = get().dailyProgress;
|
|
const data: { date: string; count: number }[] = [];
|
|
|
|
dailyProgress.forEach((progress, date) => {
|
|
data.push({
|
|
date,
|
|
count: progress.cards_studied,
|
|
});
|
|
});
|
|
|
|
return data;
|
|
},
|
|
|
|
getChartData: (type: 'accuracy' | 'cards' | 'time') => {
|
|
const dailyProgress = get().dailyProgress;
|
|
const period = get().selectedPeriod;
|
|
const data: any[] = [];
|
|
|
|
// Get date range based on period
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
|
|
switch (period) {
|
|
case 'week':
|
|
startDate.setDate(endDate.getDate() - 7);
|
|
break;
|
|
case 'month':
|
|
startDate.setDate(endDate.getDate() - 30);
|
|
break;
|
|
case 'year':
|
|
startDate.setDate(endDate.getDate() - 365);
|
|
break;
|
|
}
|
|
|
|
// Generate data points
|
|
const current = new Date(startDate);
|
|
while (current <= endDate) {
|
|
const dateStr = current.toISOString().split('T')[0];
|
|
const progress = dailyProgress.get(dateStr);
|
|
|
|
let value = 0;
|
|
switch (type) {
|
|
case 'accuracy':
|
|
value =
|
|
progress && progress.cards_studied > 0
|
|
? Math.round((progress.correct_answers / progress.cards_studied) * 100)
|
|
: 0;
|
|
break;
|
|
case 'cards':
|
|
value = progress?.cards_studied || 0;
|
|
break;
|
|
case 'time':
|
|
value = progress?.total_time_minutes || 0;
|
|
break;
|
|
}
|
|
|
|
data.push({
|
|
date: dateStr,
|
|
value,
|
|
label: current.toLocaleDateString('de-DE', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
}),
|
|
});
|
|
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
|
|
return data;
|
|
},
|
|
|
|
setSelectedPeriod: (period: 'week' | 'month' | 'year') => {
|
|
set({ selectedPeriod: period });
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}));
|