managarten/manadeck/apps/mobile/store/progressStore.ts
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
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>
2025-11-22 23:38:24 +01:00

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 }),
}));