managarten/manadeck/apps/mobile/store/studyStore.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

461 lines
14 KiB
TypeScript

import { create } from 'zustand';
import { Card } from './cardStore';
import { supabase, getAuthenticatedSupabase } from '../utils/supabase';
import { authService } from '../services/authService';
import { calculateSM2, difficultyToQuality, isCardDue } from '../utils/spacedRepetition';
import { useAuthStore } from './authStore';
export interface StudySession {
id: string;
deck_id: string;
user_id: string;
started_at: string;
ended_at?: string;
total_cards: number;
completed_cards: number;
correct_answers: number;
mode: 'all' | 'new' | 'review' | 'favorites' | 'random';
}
export interface CardProgress {
id?: string;
user_id: string;
card_id: string;
deck_id: string;
ease_factor: number;
interval: number;
repetitions: number;
next_review_date: string;
last_reviewed_at?: string;
total_reviews: number;
correct_reviews: number;
incorrect_reviews: number;
status: 'new' | 'learning' | 'review' | 'relearning';
}
export interface SessionCardProgress {
card_id: string;
session_id: string;
answered_at: string;
is_correct: boolean;
time_spent: number;
difficulty?: 'easy' | 'medium' | 'hard' | 'again';
}
interface StudyState {
// Current session
currentSession: StudySession | null;
sessionCards: Card[];
currentCardIndex: number;
cardProgressMap: Map<string, CardProgress>;
sessionProgress: SessionCardProgress[];
// UI state
isFlipped: boolean;
selectedAnswer: number | null;
showFeedback: boolean;
isLoading: boolean;
error: string | null;
// Actions
startSession: (deckId: string, mode: StudySession['mode']) => Promise<void>;
endSession: () => Promise<void>;
nextCard: () => void;
previousCard: () => void;
// Card interactions
flipCard: () => void;
selectAnswer: (answerIndex: number) => void;
submitAnswer: (
isCorrect: boolean,
difficulty?: 'easy' | 'medium' | 'hard' | 'again'
) => Promise<void>;
// Progress management
fetchCardProgress: (deckId: string) => Promise<void>;
updateCardProgress: (cardId: string, quality: number) => Promise<void>;
// Utility
resetState: () => void;
clearError: () => void;
}
export const useStudyStore = create<StudyState>((set, get) => ({
// Initial state
currentSession: null,
sessionCards: [],
currentCardIndex: 0,
cardProgressMap: new Map(),
sessionProgress: [],
isFlipped: false,
selectedAnswer: null,
showFeedback: false,
isLoading: false,
error: null,
fetchCardProgress: async (deckId: string) => {
try {
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
// Get current user ID from token
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) return;
const { data, error } = await supabase
.from('card_progress')
.select('*')
.eq('deck_id', deckId)
.eq('user_id', user.id);
if (error) {
// Check if it's a JWT expiration error
if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) {
await authService.clearAuthStorage();
// Clear user from auth store to trigger redirect to login
useAuthStore.setState({ user: null });
throw new Error('Session expired. Please sign in again.');
}
throw error;
}
const progressMap = new Map<string, CardProgress>();
data?.forEach((progress) => {
progressMap.set(progress.card_id, progress);
});
set({ cardProgressMap: progressMap });
} catch (error) {
console.error('Error fetching card progress:', error);
}
},
startSession: async (deckId: string, mode: StudySession['mode'] = 'all') => {
try {
set({ isLoading: true, error: null });
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
// Get current user ID from token
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) throw new Error('Not authenticated');
// Fetch card progress first
await get().fetchCardProgress(deckId);
// Get cards from cardStore
const { useCardStore } = await import('./cardStore');
const cardStore = useCardStore.getState();
await cardStore.fetchCards(deckId);
let cards = [...cardStore.cards];
const progressMap = get().cardProgressMap;
// Filter cards based on mode
switch (mode) {
case 'new':
// Only cards that haven't been studied yet
cards = cards.filter((card) => {
const progress = progressMap.get(card.id);
return !progress || progress.status === 'new';
});
break;
case 'review':
// Only cards due for review
cards = cards.filter((card) => {
const progress = progressMap.get(card.id);
return progress && progress.status !== 'new' && isCardDue(progress.next_review_date);
});
// Sort by most overdue first
cards.sort((a, b) => {
const progressA = progressMap.get(a.id);
const progressB = progressMap.get(b.id);
if (!progressA || !progressB) return 0;
return (
new Date(progressA.next_review_date).getTime() -
new Date(progressB.next_review_date).getTime()
);
});
break;
case 'favorites':
cards = cards.filter((card) => card.is_favorite);
break;
case 'random':
cards = cards.sort(() => Math.random() - 0.5);
break;
default:
// 'all' mode - keep cards in order
break;
}
if (cards.length === 0) {
throw new Error('Keine Karten zum Lernen gefunden');
}
// Create session in database
const { data: session, error: sessionError } = await supabase
.from('study_sessions')
.insert({
user_id: user.id,
deck_id: deckId,
mode,
total_cards: cards.length,
completed_cards: 0,
correct_answers: 0,
incorrect_answers: 0,
})
.select()
.single();
if (sessionError) {
// Check if it's a JWT expiration error
if (sessionError.code === 'PGRST303' || sessionError.message?.includes('JWT expired')) {
await authService.clearAuthStorage();
throw new Error('Session expired. Please sign in again.');
}
throw sessionError;
}
set({
currentSession: session,
sessionCards: cards,
currentCardIndex: 0,
sessionProgress: [],
isFlipped: false,
selectedAnswer: null,
showFeedback: false,
});
} catch (error: any) {
set({ error: error.message || 'Fehler beim Starten der Lernsession' });
console.error('Error starting study session:', error);
} finally {
set({ isLoading: false });
}
},
updateCardProgress: async (cardId: string, quality: number) => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const progressMap = get().cardProgressMap;
const existingProgress = progressMap.get(cardId);
const currentCard = get().sessionCards.find((c) => c.id === cardId);
if (!currentCard) return;
let newProgress: Partial<CardProgress>;
if (!existingProgress) {
// First time studying this card
const sm2Result = calculateSM2(quality, 0, 0, 2.5);
newProgress = {
user_id: user.id,
card_id: cardId,
deck_id: currentCard.deck_id,
ease_factor: sm2Result.easeFactor,
interval: sm2Result.interval,
repetitions: sm2Result.repetitions,
next_review_date: sm2Result.nextReviewDate.toISOString(),
last_reviewed_at: new Date().toISOString(),
total_reviews: 1,
correct_reviews: quality >= 3 ? 1 : 0,
incorrect_reviews: quality < 3 ? 1 : 0,
status: sm2Result.interval < 10 ? 'learning' : 'review',
};
const { error } = await supabase.from('card_progress').insert(newProgress);
if (error) throw error;
} else {
// Update existing progress
const sm2Result = calculateSM2(
quality,
existingProgress.repetitions,
existingProgress.interval,
existingProgress.ease_factor
);
let newStatus = existingProgress.status;
if (quality < 3 && existingProgress.status === 'review') {
newStatus = 'relearning';
} else if (
sm2Result.interval >= 10 &&
(existingProgress.status === 'learning' || existingProgress.status === 'new')
) {
newStatus = 'review';
}
newProgress = {
...existingProgress,
ease_factor: sm2Result.easeFactor,
interval: sm2Result.interval,
repetitions: sm2Result.repetitions,
next_review_date: sm2Result.nextReviewDate.toISOString(),
last_reviewed_at: new Date().toISOString(),
total_reviews: existingProgress.total_reviews + 1,
correct_reviews: existingProgress.correct_reviews + (quality >= 3 ? 1 : 0),
incorrect_reviews: existingProgress.incorrect_reviews + (quality < 3 ? 1 : 0),
status: newStatus,
};
const { error } = await supabase
.from('card_progress')
.update(newProgress)
.eq('id', existingProgress.id);
if (error) throw error;
}
// Update local state
progressMap.set(cardId, newProgress as CardProgress);
set({ cardProgressMap: new Map(progressMap) });
} catch (error) {
console.error('Error updating card progress:', error);
}
},
endSession: async () => {
const { currentSession, sessionProgress } = get();
if (!currentSession) return;
try {
set({ isLoading: true });
// Calculate session statistics
const correctAnswers = sessionProgress.filter((p) => p.is_correct).length;
const incorrectAnswers = sessionProgress.filter((p) => !p.is_correct).length;
// Update session in database
const { error } = await supabase
.from('study_sessions')
.update({
ended_at: new Date().toISOString(),
completed_cards: sessionProgress.length,
correct_answers: correctAnswers,
incorrect_answers: incorrectAnswers,
})
.eq('id', currentSession.id);
if (error) throw error;
// Keep the session data for the summary screen
set({
currentSession: {
...currentSession,
ended_at: new Date().toISOString(),
completed_cards: sessionProgress.length,
correct_answers: correctAnswers,
},
});
} catch (error: any) {
set({ error: error.message || 'Fehler beim Beenden der Lernsession' });
} finally {
set({ isLoading: false });
}
},
nextCard: () => {
const { currentCardIndex, sessionCards } = get();
if (currentCardIndex < sessionCards.length - 1) {
set({
currentCardIndex: currentCardIndex + 1,
isFlipped: false,
selectedAnswer: null,
showFeedback: false,
});
}
},
previousCard: () => {
const { currentCardIndex } = get();
if (currentCardIndex > 0) {
set({
currentCardIndex: currentCardIndex - 1,
isFlipped: false,
selectedAnswer: null,
showFeedback: false,
});
}
},
flipCard: () => {
set({ isFlipped: !get().isFlipped });
},
selectAnswer: (answerIndex: number) => {
const { showFeedback } = get();
if (!showFeedback) {
set({ selectedAnswer: answerIndex });
}
},
submitAnswer: async (isCorrect: boolean, difficulty?: 'easy' | 'medium' | 'hard' | 'again') => {
const { currentSession, sessionCards, currentCardIndex, sessionProgress } = get();
if (!currentSession || !sessionCards[currentCardIndex]) return;
const card = sessionCards[currentCardIndex];
// Create session progress entry
const progress: SessionCardProgress = {
card_id: card.id,
session_id: currentSession.id,
answered_at: new Date().toISOString(),
is_correct: isCorrect,
time_spent: 0, // TODO: Implement time tracking
difficulty,
};
// Update card progress with spaced repetition
if (difficulty) {
const quality = difficultyToQuality(difficulty);
await get().updateCardProgress(card.id, quality);
} else if (card.card_type === 'quiz') {
// For quiz cards, use binary correct/incorrect
const quality = isCorrect ? 4 : 1;
await get().updateCardProgress(card.id, quality);
}
set({
sessionProgress: [...sessionProgress, progress],
showFeedback: true,
});
// Auto-advance after feedback for certain card types
if (card.card_type === 'flashcard') {
setTimeout(() => {
const state = get();
if (state.currentCardIndex < state.sessionCards.length - 1) {
state.nextCard();
}
}, 1500);
}
},
resetState: () => {
set({
currentSession: null,
sessionCards: [],
currentCardIndex: 0,
cardProgressMap: new Map(),
sessionProgress: [],
isFlipped: false,
selectedAnswer: null,
showFeedback: false,
error: null,
});
},
clearError: () => set({ error: null }),
}));