- {filteredPdfs.map(pdf =>
)}
- {filteredPdfs.length === 0 &&
{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}}
+
+ {/* Search */}
+
+
+ {/* PDFs */}
+
-
- {/* Progress */}
-
- setProgressExpanded(!progressExpanded)} />
- {progressExpanded && (
-
- {progress.map(p => {
- const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
- return (
-
-
-
{p.topic}
-
{p.exercises_correct}/{p.exercises_done}
+ )}
+
+ p.id)} strategy={verticalListSortingStrategy}>
+
+ {filteredPdfs.map(pdf => )}
+ {filteredPdfs.length === 0 && (
+
+ {pdfSearch ? 'Sin resultados' : 'Sin PDFs'}
+
+ )}
-
-
- );
- })}
-
- )}
-
+
+
+ {pdfs.length === 0 && !hasLoadedPdfs && (
+ <>
+
+
+ >
+ )}
+
+
+ >
+ )}
+
- {/* Chats */}
-
- setChatsExpanded(!chatsExpanded)}
- extra={} />
- {chatsExpanded && (
-
- {conversations.map(conv => {
- const isActive = activeConversation?.id === conv.id;
- return (
-
+ )}
+
+
- {/* Notes */}
-
- setNotesExpanded(!notesExpanded)}
- extra={} />
- {notesExpanded && (
-
- {notes.slice(0, 8).map(note => (
-
- {note.title}
-
- ))}
- {notes.length === 0 && Sin notas}
-
- )}
-
-
-
-
- Settings
-
-
+
+
onNavigate?.('/timer')} className="sidebar-nav-btn">Timer
+
onNavigate?.('/roadmap')} className="sidebar-nav-btn">Roadmap
+
onNavigate?.('/exams')} className="sidebar-nav-btn">Exámenes
+
onNavigate?.('/flashcards')} className="sidebar-nav-btn" style={{ position: 'relative' }}>
+
+ Flashcards
+ {dueCount > 0 && (
+
+ {dueCount}
+
+ )}
+
+
onNavigate?.('/heatmap')} className="sidebar-nav-btn">Heatmap
+
onNavigate?.('/compare')} className="sidebar-nav-btn">Comparar PDFs
+
Settings
+
+
+
+
+
+ >
);
}
diff --git a/client/src/components/Skeleton.jsx b/client/src/components/Skeleton.jsx
new file mode 100644
index 0000000..a5b220c
--- /dev/null
+++ b/client/src/components/Skeleton.jsx
@@ -0,0 +1,21 @@
+export function Skeleton({ variant = 'conv' }) {
+ if (variant === 'pdf') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/components/StudyBuddyPanel.css b/client/src/components/StudyBuddyPanel.css
new file mode 100644
index 0000000..0e9b308
--- /dev/null
+++ b/client/src/components/StudyBuddyPanel.css
@@ -0,0 +1,152 @@
+.buddy-panel {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 16px;
+}
+
+.buddy-panel.loading {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-secondary);
+}
+
+.buddy-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+.buddy-avatars {
+ display: flex;
+ gap: 8px;
+}
+
+.buddy-avatar {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.buddy-avatar-icon {
+ font-size: 28px;
+}
+
+.buddy-role-badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: var(--accent-info);
+ color: #fff;
+}
+
+.buddy-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.buddy-share-section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.buddy-token {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.buddy-token code {
+ background: var(--bg-surface);
+ padding: 4px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ font-size: 12px;
+}
+
+.buddy-join {
+ display: flex;
+ gap: 8px;
+}
+
+.buddy-join input {
+ flex: 1;
+ padding: 8px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.buddy-btn {
+ border: none;
+ border-radius: 6px;
+ padding: 8px 14px;
+ font-size: 13px;
+ cursor: pointer;
+ background: var(--accent-info);
+ color: #fff;
+ transition: opacity 0.2s;
+}
+
+.buddy-btn:hover {
+ opacity: 0.85;
+}
+
+.buddy-feed {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-height: 60vh;
+ overflow-y: auto;
+ padding: 8px;
+ border-radius: 8px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+}
+
+.buddy-msg {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--bg-base);
+}
+
+.buddy-msg.user {
+ border-left: 3px solid var(--accent-info);
+}
+
+.buddy-msg.assistant {
+ border-left: 3px solid #27ae60;
+}
+
+.buddy-msg-role {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.buddy-msg-content {
+ font-size: 14px;
+ color: var(--text-primary);
+ white-space: pre-wrap;
+}
+
+.buddy-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
diff --git a/client/src/components/StudyBuddyPanel.jsx b/client/src/components/StudyBuddyPanel.jsx
new file mode 100644
index 0000000..f112a19
--- /dev/null
+++ b/client/src/components/StudyBuddyPanel.jsx
@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import './StudyBuddyPanel.css';
+
+export default function StudyBuddyPanel({
+ conversation,
+ messages,
+ roleLabel,
+ counterpartLabel,
+ onShare,
+ shareToken,
+ onJoin,
+ loading,
+}) {
+ const [joinToken, setJoinToken] = useState('');
+
+ if (loading) {
+ return
Cargando modo compañero...
;
+ }
+
+ return (
+
+
+
+
+ 🎓
+ {roleLabel}
+
+
+ 🎓
+ {counterpartLabel}
+
+
+
+ {conversation?.title || 'Conversación compartida'}
+
+
+
+
+ {!shareToken && (
+
+ Compartir conversación
+
+ )}
+ {shareToken && (
+
+
+ {shareToken}
+
+ )}
+
+ setJoinToken(e.target.value)}
+ />
+ onJoin(joinToken)}>
+ Unirse
+
+
+
+
+
+ {messages.map((msg) => (
+
+
+ {msg.role === 'user' ? roleLabel : msg.role === 'assistant' ? 'Tutor' : 'Sistema'}
+
+ {msg.content}
+
+ ))}
+ {messages.length === 0 && (
+
No hay mensajes aún. ¡Empezá a estudiar con tu compañero!
+ )}
+
+
+ );
+}
diff --git a/client/src/components/ThemeToggle.jsx b/client/src/components/ThemeToggle.jsx
new file mode 100644
index 0000000..3205b69
--- /dev/null
+++ b/client/src/components/ThemeToggle.jsx
@@ -0,0 +1,41 @@
+import React, { useState, useEffect } from 'react';
+import { Sun, Moon } from 'lucide-react';
+
+export default function ThemeToggle() {
+ const [theme, setTheme] = useState('dark');
+
+ useEffect(() => {
+ const stored = localStorage.getItem('studyos-theme');
+ if (stored === 'light' || stored === 'dark') {
+ setTheme(stored);
+ } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
+ setTheme('light');
+ } else {
+ setTheme('dark');
+ }
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ try {
+ localStorage.setItem('studyos-theme', theme);
+ } catch (e) {
+ // ignore
+ }
+ }, [theme]);
+
+ const toggle = () => {
+ setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
+ };
+
+ return (
+
+ {theme === 'dark' ? : }
+
+ );
+}
diff --git a/client/src/components/Toast.jsx b/client/src/components/Toast.jsx
new file mode 100644
index 0000000..aa230a1
--- /dev/null
+++ b/client/src/components/Toast.jsx
@@ -0,0 +1,75 @@
+import React, { useState, useCallback, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { Check, X } from 'lucide-react';
+
+const ToastContext = React.createContext(null);
+
+let toastIdCounter = 0;
+
+export function ToastProvider({ children }) {
+ const [toasts, setToasts] = useState([]);
+
+ const removeToast = useCallback((id) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ const addToast = useCallback((msg, type, opts = {}) => {
+ const id = ++toastIdCounter;
+ const duration = opts.duration ?? 4000;
+ setToasts((prev) => [...prev, { id, msg, type, duration }]);
+ if (duration > 0) {
+ setTimeout(() => removeToast(id), duration);
+ }
+ return id;
+ }, [removeToast]);
+
+ const success = useCallback((msg, opts) => addToast(msg, 'success', opts), [addToast]);
+ const error = useCallback((msg, opts) => addToast(msg, 'error', opts), [addToast]);
+
+ const value = React.useMemo(() => ({ success, error }), [success, error]);
+
+ // Ensure toast root exists
+ const toastRootRef = useRef(null);
+ if (!toastRootRef.current && typeof document !== 'undefined') {
+ let root = document.getElementById('toast-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'toast-root';
+ document.body.appendChild(root);
+ }
+ toastRootRef.current = root;
+ }
+
+ return (
+
+ {children}
+ {toastRootRef.current &&
+ createPortal(
+
+ {toasts.map((toast) => (
+
+
+ {toast.type === 'success' ? : }
+
+ {toast.msg}
+ removeToast(toast.id)} title="Dismiss">
+
+
+
+ ))}
+
,
+ toastRootRef.current
+ )}
+
+ );
+}
+
+export function useToast() {
+ const ctx = React.useContext(ToastContext);
+ if (!ctx) throw new Error('useToast must be used within a ToastProvider');
+ return ctx;
+}
diff --git a/client/src/components/TypingDots.jsx b/client/src/components/TypingDots.jsx
new file mode 100644
index 0000000..529e87a
--- /dev/null
+++ b/client/src/components/TypingDots.jsx
@@ -0,0 +1,9 @@
+export function TypingDots() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/VoiceInput.jsx b/client/src/components/VoiceInput.jsx
new file mode 100644
index 0000000..2764f64
--- /dev/null
+++ b/client/src/components/VoiceInput.jsx
@@ -0,0 +1,125 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Mic, MicOff } from 'lucide-react';
+
+export default function VoiceInput({ onTranscript, disabled }) {
+ const [isListening, setIsListening] = useState(false);
+ const [error, setError] = useState(null);
+ const [supported, setSupported] = useState(false);
+ const recognitionRef = useRef(null);
+
+ useEffect(() => {
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (SR) {
+ setSupported(true);
+ }
+ }, []);
+
+ const startListening = useCallback(() => {
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SR) return;
+ setError(null);
+ const recognition = new SR();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = 'es-ES';
+
+ recognition.onresult = (event) => {
+ const results = event.results;
+ if (!results || results.length === 0) return;
+ const last = results[results.length - 1];
+ if (last.isFinal) {
+ const transcript = last[0].transcript;
+ if (onTranscript) onTranscript(transcript);
+ }
+ };
+
+ recognition.onerror = (event) => {
+ if (event.error === 'not-allowed') {
+ setError('Micrófono bloqueado');
+ } else if (event.error === 'no-speech') {
+ // ignore
+ } else {
+ setError('Error de reconocimiento');
+ }
+ setIsListening(false);
+ };
+
+ recognition.onend = () => {
+ setIsListening(false);
+ };
+
+ try {
+ recognition.start();
+ recognitionRef.current = recognition;
+ setIsListening(true);
+ } catch (err) {
+ setError('No se pudo iniciar');
+ }
+ }, [onTranscript]);
+
+ const stopListening = useCallback(() => {
+ if (recognitionRef.current) {
+ try {
+ recognitionRef.current.stop();
+ } catch {
+ // ignore
+ }
+ recognitionRef.current = null;
+ }
+ setIsListening(false);
+ }, []);
+
+ const toggle = useCallback(() => {
+ if (isListening) {
+ stopListening();
+ } else {
+ startListening();
+ }
+ }, [isListening, startListening, stopListening]);
+
+ useEffect(() => {
+ return () => {
+ if (recognitionRef.current) {
+ try { recognitionRef.current.stop(); } catch {}
+ }
+ };
+ }, []);
+
+ if (!supported) return null;
+
+ return (
+
+
+ {isListening ? : }
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/client/src/context/ReactionsContext.jsx b/client/src/context/ReactionsContext.jsx
new file mode 100644
index 0000000..e79d27e
--- /dev/null
+++ b/client/src/context/ReactionsContext.jsx
@@ -0,0 +1,37 @@
+import React, { useState, useCallback } from 'react';
+
+const ReactionsContext = React.createContext(null);
+
+export function ReactionsProvider({ children }) {
+ const [reactions, setReactions] = useState({});
+
+ const get = useCallback((msgId) => {
+ return reactions[msgId] ?? null;
+ }, [reactions]);
+
+ const toggle = useCallback((msgId, emoji) => {
+ setReactions((prev) => {
+ const current = prev[msgId];
+ if (current === emoji) {
+ const next = { ...prev };
+ delete next[msgId];
+ return next;
+ }
+ return { ...prev, [msgId]: emoji };
+ });
+ }, []);
+
+ const value = React.useMemo(() => ({ get, toggle }), [get, toggle]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useReactions() {
+ const ctx = React.useContext(ReactionsContext);
+ if (!ctx) throw new Error('useReactions must be used within a ReactionsProvider');
+ return ctx;
+}
diff --git a/client/src/data/roadmap.json b/client/src/data/roadmap.json
new file mode 100644
index 0000000..e71dc75
--- /dev/null
+++ b/client/src/data/roadmap.json
@@ -0,0 +1,63 @@
+{
+ "domains": [
+ {
+ "name": "Matemáticas",
+ "color": "#818cf8",
+ "topics": [
+ { "id": "algebra", "name": "Álgebra" },
+ { "id": "calculus", "name": "Cálculo" },
+ { "id": "linear-algebra", "name": "Álgebra Lineal" },
+ { "id": "statistics", "name": "Estadística" },
+ { "id": "geometry", "name": "Geometría" }
+ ]
+ },
+ {
+ "name": "Ciencias",
+ "color": "#34d399",
+ "topics": [
+ { "id": "physics", "name": "Física" },
+ { "id": "chemistry", "name": "Química" },
+ { "id": "biology", "name": "Biología" },
+ { "id": "astronomy", "name": "Astronomía" }
+ ]
+ },
+ {
+ "name": "Informática",
+ "color": "#f472b6",
+ "topics": [
+ { "id": "programming", "name": "Programación" },
+ { "id": "algorithms", "name": "Algoritmos" },
+ { "id": "databases", "name": "Bases de Datos" },
+ { "id": "networks", "name": "Redes" },
+ { "id": "ai", "name": "Inteligencia Artificial" },
+ { "id": "os", "name": "Sistemas Operativos" }
+ ]
+ },
+ {
+ "name": "Humanidades",
+ "color": "#fbbf24",
+ "topics": [
+ { "id": "history", "name": "Historia" },
+ { "id": "philosophy", "name": "Filosofía" },
+ { "id": "literature", "name": "Literatura" }
+ ]
+ }
+ ],
+ "edges": [
+ { "from": "algebra", "to": "calculus" },
+ { "from": "calculus", "to": "linear-algebra" },
+ { "from": "calculus", "to": "statistics" },
+ { "from": "algebra", "to": "geometry" },
+ { "from": "physics", "to": "calculus" },
+ { "from": "chemistry", "to": "physics" },
+ { "from": "biology", "to": "chemistry" },
+ { "from": "astronomy", "to": "physics" },
+ { "from": "programming", "to": "algorithms" },
+ { "from": "algorithms", "to": "ai" },
+ { "from": "databases", "to": "networks" },
+ { "from": "os", "to": "networks" },
+ { "from": "programming", "to": "os" },
+ { "from": "history", "to": "philosophy" },
+ { "from": "philosophy", "to": "literature" }
+ ]
+}
diff --git a/client/src/hooks/__tests__/useChat.test.js b/client/src/hooks/__tests__/useChat.test.js
new file mode 100644
index 0000000..bfc67d5
--- /dev/null
+++ b/client/src/hooks/__tests__/useChat.test.js
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import useChat from '../useChat';
+import * as api from '../../lib/api';
+
+vi.mock('../../lib/api');
+
+const mockToastError = vi.fn();
+vi.mock('../../components/Toast', () => ({
+ ToastProvider: ({ children }) => children,
+ useToast: () => ({ error: mockToastError }),
+}));
+
+describe('useChat', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.getMessages.mockResolvedValue({ messages: [] });
+ api.postChatStream.mockResolvedValue({
+ body: {
+ getReader() {
+ return {
+ async read() {
+ return { done: true, value: undefined };
+ },
+ releaseLock() {},
+ };
+ },
+ },
+ });
+ api.streamSSE = async function* mockSSE() {
+ yield { type: 'done' };
+ };
+ });
+
+ it('blocks sendMessage when isStreaming and shows toast', async () => {
+ const { result } = renderHook(() => useChat({ conversationId: '1' }));
+
+ await act(async () => {
+ await result.current.setActiveId('1');
+ });
+
+ // Trigger first message to set isStreaming
+ act(() => {
+ result.current.sendMessage('hello');
+ });
+
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
+
+ // Try sending again while streaming
+ await act(async () => {
+ await result.current.sendMessage('second');
+ });
+
+ // postChatStream called only once
+ expect(api.postChatStream).toHaveBeenCalledTimes(1);
+ // Toast shown
+ expect(mockToastError).toHaveBeenCalledWith('Message already in progress');
+ });
+});
diff --git a/client/src/hooks/__tests__/usePdfs.test.js b/client/src/hooks/__tests__/usePdfs.test.js
new file mode 100644
index 0000000..795b5f7
--- /dev/null
+++ b/client/src/hooks/__tests__/usePdfs.test.js
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import usePdfs from '../usePdfs';
+import * as api from '../../lib/api';
+
+vi.mock('../../lib/api');
+
+describe('usePdfs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.getPdfs.mockResolvedValue([
+ { id: 1, filename: 'a.pdf', sort_order: 0 },
+ ]);
+ });
+
+ it('refresh loads PDFs', async () => {
+ const { result } = renderHook(() => usePdfs());
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.pdfs).toHaveLength(1);
+ expect(api.getPdfs).toHaveBeenCalledTimes(1);
+ });
+
+ it('uploadPdf refreshes list', async () => {
+ const { result } = renderHook(() => usePdfs());
+ const file = new File(['content'], 'test.pdf');
+ api.uploadPdf.mockResolvedValue({});
+
+ await act(async () => {
+ await result.current.uploadPdf(file);
+ });
+
+ expect(api.uploadPdf).toHaveBeenCalledWith(file);
+ expect(api.getPdfs).toHaveBeenCalled();
+ });
+
+ it('reorderPdf refreshes list', async () => {
+ const { result } = renderHook(() => usePdfs());
+ api.reorderPdf.mockResolvedValue({});
+
+ await act(async () => {
+ await result.current.reorderPdf(1, 2);
+ });
+
+ expect(api.reorderPdf).toHaveBeenCalledWith(1, 2);
+ });
+
+ it('deletePdf refreshes list', async () => {
+ const { result } = renderHook(() => usePdfs());
+ api.deletePdf.mockResolvedValue({});
+
+ await act(async () => {
+ await result.current.deletePdf(1);
+ });
+
+ expect(api.deletePdf).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/client/src/hooks/__tests__/useProgress.test.js b/client/src/hooks/__tests__/useProgress.test.js
new file mode 100644
index 0000000..a2056f2
--- /dev/null
+++ b/client/src/hooks/__tests__/useProgress.test.js
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import useProgress from '../useProgress';
+import * as api from '../../lib/api';
+
+vi.mock('../../lib/api');
+
+describe('useProgress', () => {
+ let wsInstances = [];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.getProgress.mockResolvedValue([]);
+
+ global.WebSocket = vi.fn(function MockWebSocket(url) {
+ this.url = url;
+ this.readyState = 0;
+ this.onopen = null;
+ this.onmessage = null;
+ this.onclose = null;
+ this.onerror = null;
+ this.close = vi.fn(() => {
+ this.readyState = 3;
+ if (this.onclose) this.onclose();
+ });
+ this.send = vi.fn();
+ wsInstances.push(this);
+ });
+ });
+
+ afterEach(() => {
+ wsInstances = [];
+ vi.restoreAllMocks();
+ });
+
+ it('updates state on progress_update message', async () => {
+ api.getProgress.mockResolvedValue([
+ { topic: 'math', exercises_done: 2, exercises_correct: 1 },
+ ]);
+
+ const { result } = renderHook(() => useProgress());
+
+ await waitFor(() => expect(result.current.progress.length).toBe(1));
+
+ const ws = wsInstances[0];
+ act(() => {
+ ws.onmessage({
+ data: JSON.stringify({
+ type: 'progress_update',
+ data: { topic: 'math', exercises_done: 3, exercises_correct: 2 },
+ }),
+ });
+ });
+
+ await waitFor(() =>
+ expect(result.current.progress[0].percentage).toBe(67)
+ );
+ });
+
+ it('closes socket on unmount', async () => {
+ api.getProgress.mockResolvedValue([]);
+
+ const { unmount } = renderHook(() => useProgress());
+ const ws = wsInstances[0];
+
+ unmount();
+ expect(ws.close).toHaveBeenCalled();
+ });
+});
diff --git a/client/src/hooks/useChat.js b/client/src/hooks/useChat.js
index 1d9075c..afbcb89 100644
--- a/client/src/hooks/useChat.js
+++ b/client/src/hooks/useChat.js
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { getMessages, postChatStream, streamSSE, updateProgress } from '../lib/api';
+import { useToast } from '../components/Toast';
function extractExerciseJson(text) {
const fenceRegex = /```json\s*([\s\S]*?)\s*```/;
@@ -29,12 +30,15 @@ function extractExerciseJson(text) {
return null;
}
-export default function useChat({ conversationId, onProgressUpdate }) {
+export default function useChat({ conversationId, onProgressUpdate, onStudySession, onAutoFork, onDifficultyChanged }) {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const [activeId, setActiveIdState] = useState(conversationId);
+ const [autoForkPrompt, setAutoForkPrompt] = useState(null);
+ const [difficultyChanged, setDifficultyChanged] = useState(null);
const abortRef = useRef(null);
const activeIdRef = useRef(activeId);
+ const toast = useToast();
// Sync activeIdRef with current activeId
useEffect(() => {
@@ -72,7 +76,10 @@ export default function useChat({ conversationId, onProgressUpdate }) {
const sendMessage = useCallback(
async (text, pdfIds = [], attachments = []) => {
- if (isStreaming) return;
+ if (isStreaming) {
+ toast.error('Message already in progress');
+ return;
+ }
if (!activeId || !text.trim()) return;
const controller = new AbortController();
@@ -87,6 +94,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
setMessages((prev) => [...prev, userMsg]);
setIsStreaming(true);
+ setAutoForkPrompt(null);
+ setDifficultyChanged(null);
const assistantMsg = {
id: `temp-assist-${Date.now()}`,
@@ -96,6 +105,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
};
setMessages((prev) => [...prev, assistantMsg]);
+ let streamSuccess = false;
try {
const response = await postChatStream({
conversation_id: activeId,
@@ -107,21 +117,40 @@ export default function useChat({ conversationId, onProgressUpdate }) {
let fullText = '';
for await (const event of streamSSE(response)) {
if (controller.signal.aborted) break;
- if (event.type === 'token') {
+ if (event.token !== undefined) {
fullText += event.token;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
- } else if (event.type === 'done') {
+ } else if (event.done) {
fullText = event.full_text || fullText;
+ } else if (event.auto_fork_suggest) {
+ setAutoForkPrompt(event.auto_fork_suggest);
+ if (onAutoFork) {
+ onAutoFork({
+ topic: event.auto_fork_suggest.topic,
+ parentId: event.auto_fork_suggest.parent_id,
+ wrongStreak: event.auto_fork_suggest.wrong_streak,
+ });
+ }
+ } else if (event.difficulty_changed) {
+ setDifficultyChanged(event.difficulty_changed);
+ if (onDifficultyChanged) {
+ onDifficultyChanged({
+ level: event.difficulty_changed.level,
+ globalWrongStreak: event.difficulty_changed.global_wrong_streak,
+ });
+ }
} else if (event.error) {
console.error('[useChat] stream error:', event.error);
break;
}
}
+ streamSuccess = true;
+
// Exercise JSON parsing after streaming
const extracted = extractExerciseJson(fullText);
if (extracted && extracted.exercise) {
@@ -147,6 +176,16 @@ export default function useChat({ conversationId, onProgressUpdate }) {
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
+
+ // Record study session after successful reply
+ if (onStudySession) {
+ const today = new Date().toISOString().split('T')[0];
+ try {
+ await onStudySession(today, 1);
+ } catch (err) {
+ console.error('[useChat] study session error:', err.message);
+ }
+ }
} catch (err) {
if (err.name === 'AbortError') return;
console.error('[useChat] send error:', err.message);
@@ -163,8 +202,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
}
setIsStreaming(false);
if (controller.signal.aborted) return;
- // Refresh messages from server to get persisted IDs only for current conversation
- if (activeIdRef.current === activeId) {
+ // Only refresh messages from server on success, to avoid overwriting error state
+ if (streamSuccess && activeIdRef.current === activeId) {
try {
const data = await getMessages(activeId);
setMessages(data.messages || []);
@@ -174,7 +213,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
}
}
},
- [activeId, onProgressUpdate, isStreaming]
+ [activeId, onProgressUpdate, onAutoFork, onDifficultyChanged, onStudySession, isStreaming, toast]
);
return {
@@ -183,5 +222,9 @@ export default function useChat({ conversationId, onProgressUpdate }) {
activeId,
setActiveId,
sendMessage,
+ autoForkPrompt,
+ setAutoForkPrompt,
+ difficultyChanged,
+ setDifficultyChanged,
};
}
diff --git a/client/src/hooks/useExam.js b/client/src/hooks/useExam.js
new file mode 100644
index 0000000..db37ff9
--- /dev/null
+++ b/client/src/hooks/useExam.js
@@ -0,0 +1,119 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { submitExam } from '../lib/api';
+
+export default function useExam(exam) {
+ const [currentQuestion, setCurrentQuestion] = useState(0);
+ const [answers, setAnswers] = useState([]);
+ const [remainingSeconds, setRemainingSeconds] = useState(0);
+ const [status, setStatus] = useState('idle'); // idle, running, submitted, expired
+ const [result, setResult] = useState(null);
+ const timerRef = useRef(null);
+ const answersRef = useRef(answers);
+ const examRef = useRef(exam);
+
+ useEffect(() => { answersRef.current = answers; }, [answers]);
+ useEffect(() => { examRef.current = exam; }, [exam]);
+
+ // Initialize when exam data arrives
+ useEffect(() => {
+ if (!exam || !exam.questions) {
+ setStatus('idle');
+ setRemainingSeconds(0);
+ return;
+ }
+ setAnswers(new Array(exam.questions.length).fill(null));
+ setCurrentQuestion(0);
+ setResult(null);
+
+ const startedAt = new Date(exam.started_at).getTime();
+ const durationMs = (exam.duration_seconds || 0) * 1000;
+ const now = Date.now();
+ const elapsed = now - startedAt;
+ const remaining = Math.max(0, Math.ceil((durationMs - elapsed) / 1000));
+
+ if (remaining <= 0) {
+ setStatus('expired');
+ setRemainingSeconds(0);
+ } else {
+ setStatus('running');
+ setRemainingSeconds(remaining);
+ }
+ }, [exam]);
+
+ const handleAutoSubmit = useCallback(async () => {
+ const currentExam = examRef.current;
+ const currentAnswers = answersRef.current;
+ if (!currentExam?.id) return;
+ try {
+ const res = await submitExam(currentExam.id, currentAnswers);
+ setResult(res);
+ setStatus('submitted');
+ } catch (err) {
+ console.error('[useExam] auto-submit error:', err.message);
+ setStatus('expired');
+ }
+ }, []);
+
+ // Countdown timer
+ useEffect(() => {
+ if (status !== 'running') return;
+ timerRef.current = setInterval(() => {
+ setRemainingSeconds((prev) => {
+ if (prev <= 1) {
+ clearInterval(timerRef.current);
+ // Auto-submit at 0
+ handleAutoSubmit();
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ return () => clearInterval(timerRef.current);
+ }, [status, handleAutoSubmit]);
+
+ const handleSubmit = useCallback(async () => {
+ if (!exam || !exam.id || status !== 'running') return;
+ clearInterval(timerRef.current);
+ try {
+ const res = await submitExam(exam.id, answers);
+ setResult(res);
+ setStatus('submitted');
+ } catch (err) {
+ console.error('[useExam] submit error:', err.message);
+ setStatus('expired');
+ }
+ }, [exam, answers, status]);
+
+ const setAnswer = useCallback((questionIndex, answer) => {
+ setAnswers((prev) => {
+ const next = [...prev];
+ next[questionIndex] = answer;
+ return next;
+ });
+ }, []);
+
+ const goNext = useCallback(() => {
+ setCurrentQuestion((q) => Math.min(q + 1, (exam?.questions?.length || 1) - 1));
+ }, [exam]);
+
+ const goPrev = useCallback(() => {
+ setCurrentQuestion((q) => Math.max(q - 1, 0));
+ }, []);
+
+ const percentTimeRemaining = exam && exam.duration_seconds
+ ? Math.min(100, Math.max(0, (remainingSeconds / exam.duration_seconds) * 100))
+ : 100;
+
+ return {
+ currentQuestion,
+ answers,
+ remainingSeconds,
+ status,
+ result,
+ setAnswer,
+ goNext,
+ goPrev,
+ handleSubmit,
+ percentTimeRemaining,
+ };
+}
diff --git a/client/src/hooks/useExams.js b/client/src/hooks/useExams.js
new file mode 100644
index 0000000..26512bc
--- /dev/null
+++ b/client/src/hooks/useExams.js
@@ -0,0 +1,79 @@
+import { useState, useCallback } from 'react';
+import {
+ getExams,
+ createExam as apiCreateExam,
+ updateExam as apiUpdateExam,
+ deleteExam as apiDeleteExam,
+} from '../lib/api';
+
+export default function useExams() {
+ const [exams, setExams] = useState([]);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const rows = await getExams();
+ setExams(rows);
+ setError(null);
+ } catch (err) {
+ console.error('[useExams] refresh error:', err.message);
+ setError(err.message);
+ }
+ }, []);
+
+ const create = useCallback(async (body) => {
+ try {
+ const row = await apiCreateExam(body);
+ setExams((prev) => [row, ...prev]);
+ setError(null);
+ return row;
+ } catch (err) {
+ console.error('[useExams] create error:', err.message);
+ setError(err.message);
+ throw err;
+ }
+ }, []);
+
+ const update = useCallback(async (id, body) => {
+ try {
+ const row = await apiUpdateExam(id, body);
+ setExams((prev) => prev.map((e) => (e.id === id ? row : e)));
+ setError(null);
+ return row;
+ } catch (err) {
+ console.error('[useExams] update error:', err.message);
+ setError(err.message);
+ throw err;
+ }
+ }, []);
+
+ const remove = useCallback(async (id) => {
+ try {
+ await apiDeleteExam(id);
+ setExams((prev) => prev.filter((e) => e.id !== id));
+ setError(null);
+ } catch (err) {
+ console.error('[useExams] delete error:', err.message);
+ setError(err.message);
+ throw err;
+ }
+ }, []);
+
+ const filterByTopic = useCallback(
+ (topic) => {
+ if (!topic) return exams;
+ return exams.filter((e) => e.topics && e.topics.includes(topic));
+ },
+ [exams]
+ );
+
+ return {
+ exams,
+ error,
+ refresh,
+ create,
+ update,
+ remove,
+ filterByTopic,
+ };
+}
diff --git a/client/src/hooks/useFlashcards.js b/client/src/hooks/useFlashcards.js
new file mode 100644
index 0000000..e7ca928
--- /dev/null
+++ b/client/src/hooks/useFlashcards.js
@@ -0,0 +1,162 @@
+import { useState, useCallback, useEffect } from 'react';
+import {
+ generateFlashcards as apiGenerateFlashcards,
+ getFlashcards,
+ updateFlashcard as apiUpdateFlashcard,
+ deleteFlashcard as apiDeleteFlashcard,
+ getFlashcardReviews,
+ reviewFlashcard as apiReviewFlashcard,
+ streamSSE,
+} from '../lib/api';
+
+export default function useFlashcards() {
+ const [cards, setCards] = useState([]);
+ const [queue, setQueue] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [streamEvents, setStreamEvents] = useState([]);
+ const [dueCount, setDueCount] = useState(0);
+ const [dueCards, setDueCards] = useState([]);
+
+ const refresh = useCallback(async (params = {}) => {
+ try {
+ const rows = await getFlashcards(params);
+ setCards(rows);
+ } catch (err) {
+ console.error('[useFlashcards] refresh error:', err.message);
+ }
+ }, []);
+
+ const refreshDue = useCallback(async () => {
+ try {
+ const data = await getFlashcardReviews();
+ setDueCount(data?.count || 0);
+ setDueCards(data?.cards || []);
+ } catch (err) {
+ console.error('[useFlashcards] refreshDue error:', err.message);
+ setDueCount(0);
+ setDueCards([]);
+ }
+ }, []);
+
+ // Load due count on mount
+ useEffect(() => {
+ refreshDue();
+ }, [refreshDue]);
+
+ const generate = useCallback(async (body) => {
+ setLoading(true);
+ setStreamEvents([]);
+ try {
+ const response = await apiGenerateFlashcards(body);
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6);
+ try {
+ const event = JSON.parse(data);
+ setStreamEvents((prev) => [...prev, event]);
+ if (event.type === 'card') {
+ setCards((prev) => [event.card, ...prev]);
+ setQueue((prev) => [...prev, event.card]);
+ }
+ } catch {
+ // ignore malformed SSE lines
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error('[useFlashcards] generate error:', err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchNext = useCallback(async () => {
+ try {
+ const rows = await getFlashcards({ seen: 0 });
+ setQueue(rows);
+ } catch (err) {
+ console.error('[useFlashcards] fetchNext error:', err.message);
+ }
+ }, []);
+
+ const reveal = useCallback((id) => {
+ setQueue((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, _revealed: true } : c))
+ );
+ }, []);
+
+ const markSeen = useCallback(async (id) => {
+ try {
+ const row = await apiUpdateFlashcard(id, { seen: true });
+ setQueue((prev) => prev.filter((c) => c.id !== id));
+ setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
+ } catch (err) {
+ console.error('[useFlashcards] markSeen error:', err.message);
+ }
+ }, []);
+
+ const update = useCallback(async (id, body) => {
+ try {
+ const row = await apiUpdateFlashcard(id, body);
+ setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
+ setQueue((prev) => prev.map((c) => (c.id === id ? row : c)));
+ return row;
+ } catch (err) {
+ console.error('[useFlashcards] update error:', err.message);
+ throw err;
+ }
+ }, []);
+
+ const remove = useCallback(async (id) => {
+ try {
+ await apiDeleteFlashcard(id);
+ setCards((prev) => prev.filter((c) => c.id !== id));
+ setQueue((prev) => prev.filter((c) => c.id !== id));
+ } catch (err) {
+ console.error('[useFlashcards] delete error:', err.message);
+ throw err;
+ }
+ }, []);
+
+ const review = useCallback(async (id, quality) => {
+ try {
+ // Server authoritative write
+ const result = await apiReviewFlashcard(id, quality);
+
+ // Refresh due list
+ await refreshDue();
+ return result;
+ } catch (err) {
+ console.error('[useFlashcards] review error:', err.message);
+ throw err;
+ }
+ }, [dueCards, refreshDue]);
+
+ return {
+ cards,
+ queue,
+ loading,
+ generate,
+ fetchNext,
+ reveal,
+ markSeen,
+ update,
+ remove,
+ streamEvents,
+ dueCount,
+ dueCards,
+ review,
+ refreshDue,
+ };
+}
diff --git a/client/src/hooks/useMediaQuery.js b/client/src/hooks/useMediaQuery.js
new file mode 100644
index 0000000..57a2eea
--- /dev/null
+++ b/client/src/hooks/useMediaQuery.js
@@ -0,0 +1,18 @@
+import { useState, useEffect } from 'react';
+
+export function useMediaQuery(query) {
+ const [matches, setMatches] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia(query).matches;
+ });
+
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const handler = (e) => setMatches(e.matches);
+ mql.addEventListener('change', handler);
+ setMatches(mql.matches);
+ return () => mql.removeEventListener('change', handler);
+ }, [query]);
+
+ return matches;
+}
diff --git a/client/src/hooks/useProgress.js b/client/src/hooks/useProgress.js
index 9c7004b..62caba0 100644
--- a/client/src/hooks/useProgress.js
+++ b/client/src/hooks/useProgress.js
@@ -1,4 +1,4 @@
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
import { getProgress, updateProgress, resetProgressTopic } from '../lib/api';
function calcPercentage(row) {
@@ -9,6 +9,9 @@ function calcPercentage(row) {
export default function useProgress() {
const [progress, setProgress] = useState([]);
const [error, setError] = useState(null);
+ const wsRef = useRef(null);
+ const reconnectDelayRef = useRef(1000);
+ const reconnectTimerRef = useRef(null);
const refresh = useCallback(async () => {
try {
@@ -28,6 +31,62 @@ export default function useProgress() {
useEffect(() => {
refresh();
+
+ const connect = () => {
+ const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ reconnectDelayRef.current = 1000;
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'progress_update' && msg.data) {
+ setProgress((prev) => {
+ const row = msg.data;
+ const enriched = { ...row, percentage: calcPercentage(row) };
+ const idx = prev.findIndex((p) => p.topic === row.topic);
+ if (idx >= 0) {
+ const next = [...prev];
+ next[idx] = enriched;
+ return next;
+ }
+ return [...prev, enriched];
+ });
+ }
+ } catch (e) {
+ // ignore malformed messages
+ }
+ };
+
+ ws.onclose = () => {
+ wsRef.current = null;
+ const delay = Math.min(reconnectDelayRef.current, 30000);
+ reconnectDelayRef.current *= 2;
+ reconnectTimerRef.current = setTimeout(connect, delay);
+ };
+
+ ws.onerror = () => {
+ ws.close();
+ };
+ };
+
+ connect();
+
+ return () => {
+ if (wsRef.current) {
+ wsRef.current.onclose = null;
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current);
+ reconnectTimerRef.current = null;
+ }
+ };
}, [refresh]);
const updateExercise = useCallback(
diff --git a/client/src/hooks/useSearch.js b/client/src/hooks/useSearch.js
new file mode 100644
index 0000000..5e5be50
--- /dev/null
+++ b/client/src/hooks/useSearch.js
@@ -0,0 +1,44 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { searchApi } from '../lib/api';
+
+export default function useSearch() {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const debounceRef = useRef(null);
+
+ const clear = useCallback(() => {
+ setQuery('');
+ setResults([]);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ const trimmed = query.trim();
+ if (!trimmed) {
+ setResults([]);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ debounceRef.current = setTimeout(async () => {
+ try {
+ const data = await searchApi(trimmed);
+ setResults(data || []);
+ } catch (err) {
+ console.error('[useSearch] error:', err.message);
+ setResults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, 300);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [query]);
+
+ return { query, setQuery, results, loading, clear };
+}
diff --git a/client/src/hooks/useStudyBuddy.js b/client/src/hooks/useStudyBuddy.js
new file mode 100644
index 0000000..e228f9a
--- /dev/null
+++ b/client/src/hooks/useStudyBuddy.js
@@ -0,0 +1,95 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { getSharedConversation, getMessages } from '../lib/api';
+
+export default function useStudyBuddy(token) {
+ const [conversation, setConversation] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [roleLabel, setRoleLabel] = useState('compañero');
+ const [counterpartLabel, setCounterpartLabel] = useState('compañero');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const wsRef = useRef(null);
+ const convIdRef = useRef(null);
+
+ // Load shared conversation from token
+ const loadShared = useCallback(async () => {
+ if (!token) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await getSharedConversation(token);
+ setConversation(data.conversation);
+ setMessages(data.messages || []);
+ setRoleLabel(data.role_label || 'compañero');
+ setCounterpartLabel(data.counterpart_label || 'compañero');
+ convIdRef.current = data.conversation?.id ?? null;
+ } catch (err) {
+ console.error('[useStudyBuddy] load error:', err.message);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [token]);
+
+ useEffect(() => {
+ loadShared();
+ }, [loadShared]);
+
+ // WebSocket connection for real-time updates
+ useEffect(() => {
+ if (!conversation?.id) return;
+
+ const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ console.log('[useStudyBuddy] WS connected');
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const payload = JSON.parse(event.data);
+ if (payload.type === 'buddy_msg' && payload.conv_id === conversation.id) {
+ // Refresh messages
+ refreshMessages();
+ }
+ } catch {
+ // ignore non-JSON messages
+ }
+ };
+
+ ws.onerror = (err) => {
+ console.error('[useStudyBuddy] WS error:', err);
+ };
+
+ ws.onclose = () => {
+ console.log('[useStudyBuddy] WS disconnected');
+ };
+
+ return () => {
+ ws.close();
+ };
+ }, [conversation?.id]);
+
+ const refreshMessages = useCallback(async () => {
+ const convId = convIdRef.current;
+ if (!convId) return;
+ try {
+ const data = await getMessages(convId);
+ setMessages(data.messages || []);
+ } catch (err) {
+ console.error('[useStudyBuddy] refresh error:', err.message);
+ }
+ }, []);
+
+ return {
+ conversation,
+ messages,
+ roleLabel,
+ counterpartLabel,
+ loading,
+ error,
+ refreshMessages,
+ };
+}
diff --git a/client/src/hooks/useStudySessions.js b/client/src/hooks/useStudySessions.js
new file mode 100644
index 0000000..ac2af11
--- /dev/null
+++ b/client/src/hooks/useStudySessions.js
@@ -0,0 +1,31 @@
+import { useState, useCallback } from 'react';
+import { recordStudySession as apiRecordSession, getHeatmap } from '../lib/api';
+
+export default function useStudySessions() {
+ const [heatmapData, setHeatmapData] = useState([]);
+
+ const recordSession = useCallback(async (date, minutes, topic) => {
+ try {
+ await apiRecordSession(date, minutes, topic);
+ } catch (err) {
+ console.error('[useStudySessions] record error:', err.message);
+ }
+ }, []);
+
+ const heatmap = useCallback(async (days = 365) => {
+ try {
+ const rows = await getHeatmap(days);
+ setHeatmapData(rows);
+ return rows;
+ } catch (err) {
+ console.error('[useStudySessions] heatmap error:', err.message);
+ return [];
+ }
+ }, []);
+
+ return {
+ recordSession,
+ heatmap,
+ heatmapData,
+ };
+}
diff --git a/client/src/index.css b/client/src/index.css
index dd729cc..c7d3aac 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -37,6 +37,32 @@
html, body, #root { height: 100%; }
html { scroll-behavior: smooth; }
+[data-theme="light"] {
+ --bg-base: #f8f9fb;
+ --bg-surface: #ffffff;
+ --bg-elevated: #f0f2f7;
+ --bg-glass: rgba(0,0,0,0.03);
+ --border: #e2e5ec;
+ --border-glow: #d0d4e0;
+ --text-primary: #1a1c23;
+ --text-secondary: #5b5f6e;
+ --text-tertiary: #8b8fa0;
+ --accent-green: #10b981;
+ --accent-amber: #f59e0b;
+ --accent-coral: #ef4444;
+ --accent-info: #6366f1;
+ --accent-purple: #8b5cf6;
+ --accent-pink: #ec4899;
+ --accent-cyan: #06b6d4;
+ --accent-orange: #f97316;
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
+ --shadow-md: 0 4px 14px rgba(0,0,0,0.08);
+ --shadow-lg: 0 8px 32px rgba(0,0,0,0.10);
+ --shadow-glow-green: 0 0 20px rgba(16,185,129,0.12);
+ --shadow-glow-purple: 0 0 24px rgba(139,92,246,0.10);
+ --shadow-glow-blue: 0 0 20px rgba(99,102,241,0.10);
+}
+
body {
background: linear-gradient(135deg, #0f1117 0%, #13152a 30%, #0f172a 60%, #0f1117 100%);
color: var(--text-primary);
@@ -66,3 +92,84 @@ code, pre { font-family: var(--font-mono); }
@keyframes scaleIn { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} }
@keyframes glowPulse { 0%,100%{box-shadow:0 0 8px rgba(129,140,248,0.2)} 50%{box-shadow:0 0 20px rgba(129,140,248,0.4)} }
@keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
+
+@keyframes typing-bounce {
+ 0%, 60%, 100% { transform: translateY(0); }
+ 30% { transform: translateY(-6px); }
+}
+
+@keyframes skeleton-shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+@keyframes fade-in {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 0.4; transform: scale(0.9); }
+ 50% { opacity: 1; transform: scale(1.1); }
+}
+
+/* Toast portal */
+#toast-root { position: fixed; top: 16px; right: 16px; z-index: 200; pointer-events: none; }
+.toast-portal { display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
+.toast-card {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px 14px; border-radius: var(--radius-md);
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ color: var(--text-primary); font-size: 13px; min-width: 220px; max-width: 360px;
+ box-shadow: var(--shadow-lg);
+ animation: fade-in 0.3s ease;
+}
+.toast-card.toast-success { background: linear-gradient(135deg, rgba(52,211,153,0.12), rgba(52,211,153,0.06)); border-color: rgba(52,211,153,0.35); }
+.toast-card.toast-error { background: linear-gradient(135deg, rgba(248,113,113,0.12), rgba(248,113,113,0.06)); border-color: rgba(248,113,113,0.35); }
+.toast-icon { display: flex; align-items: center; flex-shrink: 0; }
+.toast-msg { flex: 1; line-height: 1.4; }
+.toast-close { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: var(--radius-sm); transition: color var(--transition-fast); }
+.toast-close:hover { color: var(--text-primary); }
+
+/* Typing dots */
+.typing-dots { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px; }
+.typing-dot {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: var(--accent-info);
+ animation: typing-bounce 1.2s ease-in-out infinite;
+}
+.typing-dot:nth-child(1) { animation-delay: 0ms; }
+.typing-dot:nth-child(2) { animation-delay: 150ms; }
+.typing-dot:nth-child(3) { animation-delay: 300ms; }
+
+/* Skeleton */
+.skeleton-card {
+ position: relative; overflow: hidden;
+ background: var(--bg-elevated); border-radius: var(--radius-sm);
+ border: 1px solid var(--border); margin-bottom: 6px;
+}
+.skeleton-shimmer {
+ position: absolute; inset: 0;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent);
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.6s infinite;
+ pointer-events: none;
+}
+.skeleton-conv { padding: 10px 14px; }
+.skeleton-pdf { padding: 8px 10px; display: flex; align-items: center; gap: 8px; }
+.skeleton-grip { width: 14px; height: 14px; border-radius: 2px; background: var(--border); flex-shrink: 0; }
+.skeleton-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; }
+.skeleton-line { height: 10px; border-radius: var(--radius-pill); background: var(--border); }
+.skeleton-line--text { width: 75%; height: 14px; }
+.skeleton-line--short { width: 60%; }
+.skeleton-line--shorter { width: 40%; }
+
+/* Mobile sidebar */
+@media (max-width: 767px) {
+ .app-sidebar {
+ position: fixed !important; top: 0; left: 0; height: 100vh;
+ transform: translateX(-100%);
+ z-index: 30; transition: transform var(--transition-slow);
+ }
+}
+
diff --git a/client/src/lib/__tests__/api.test.js b/client/src/lib/__tests__/api.test.js
new file mode 100644
index 0000000..876de65
--- /dev/null
+++ b/client/src/lib/__tests__/api.test.js
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { backupDatabase, restoreDatabase } from '../api';
+
+describe('api', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ describe('backupDatabase', () => {
+ it('fetches backup endpoint and triggers download', async () => {
+ const blob = new Blob(['db-content']);
+ global.fetch.mockResolvedValue({
+ ok: true,
+ blob: () => Promise.resolve(blob),
+ });
+
+ const createObjectURL = vi.fn(() => 'blob:test');
+ const revokeObjectURL = vi.fn();
+ URL.createObjectURL = createObjectURL;
+ URL.revokeObjectURL = revokeObjectURL;
+
+ const clickSpy = vi.fn();
+ const anchorSpy = vi.spyOn(document, 'createElement').mockReturnValue({
+ href: '',
+ download: '',
+ click: clickSpy,
+ });
+
+ await backupDatabase();
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/config/backup');
+ expect(createObjectURL).toHaveBeenCalledWith(blob);
+ expect(clickSpy).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:test');
+
+ anchorSpy.mockRestore();
+ });
+
+ it('throws on non-ok response', async () => {
+ global.fetch.mockResolvedValue({ ok: false, status: 404 });
+ await expect(backupDatabase()).rejects.toThrow('Backup failed: 404');
+ });
+ });
+
+ describe('restoreDatabase', () => {
+ it('posts file as FormData', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ ok: true }),
+ });
+
+ const file = new File(['db'], 'studyos.db');
+ await restoreDatabase(file);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/config/restore',
+ expect.objectContaining({
+ method: 'POST',
+ body: expect.any(FormData),
+ })
+ );
+ });
+
+ it('throws on non-ok response', async () => {
+ global.fetch.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve('bad') });
+ const file = new File(['db'], 'studyos.db');
+ await expect(restoreDatabase(file)).rejects.toThrow('Restore failed: 400 bad');
+ });
+ });
+});
diff --git a/client/src/lib/api.js b/client/src/lib/api.js
index 8859074..6c9890d 100644
--- a/client/src/lib/api.js
+++ b/client/src/lib/api.js
@@ -83,6 +83,19 @@ export async function createConversation(body) {
}
}
+export async function updateConversation(id, body) {
+ try {
+ const res = await fetch(`${API_BASE}/conversations/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to update conversation: ${err.message}`);
+ }
+}
+
export async function getMessages(convId) {
try {
const res = await fetch(`${API_BASE}/conversations/${convId}/messages`);
@@ -250,6 +263,128 @@ export async function testModel(id) {
}
}
+// Exams
+export async function getExams(topic) {
+ try {
+ const url = topic ? `${API_BASE}/exams?topic=${encodeURIComponent(topic)}` : `${API_BASE}/exams`;
+ const res = await fetch(url);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch exams: ${err.message}`);
+ }
+}
+
+export async function createExam(body) {
+ try {
+ const res = await fetch(`${API_BASE}/exams`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to create exam: ${err.message}`);
+ }
+}
+
+export async function updateExam(id, body) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to update exam: ${err.message}`);
+ }
+}
+
+export async function deleteExam(id) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/${id}`, { method: 'DELETE' });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to delete exam: ${err.message}`);
+ }
+}
+
+// Flashcards
+export async function generateFlashcards(body) {
+ try {
+ const res = await fetch(`${API_BASE}/flashcards/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`Failed to generate flashcards: ${res.status} ${text}`);
+ }
+ return res;
+ } catch (err) {
+ throw new Error(`Failed to generate flashcards: ${err.message}`);
+ }
+}
+
+export async function getFlashcards(params = {}) {
+ try {
+ const qs = new URLSearchParams();
+ if (params.seen !== undefined) qs.append('seen', params.seen);
+ if (params.topic) qs.append('topic', params.topic);
+ const url = qs.toString() ? `${API_BASE}/flashcards?${qs.toString()}` : `${API_BASE}/flashcards`;
+ const res = await fetch(url);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch flashcards: ${err.message}`);
+ }
+}
+
+export async function updateFlashcard(id, body) {
+ try {
+ const res = await fetch(`${API_BASE}/flashcards/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to update flashcard: ${err.message}`);
+ }
+}
+
+export async function deleteFlashcard(id) {
+ try {
+ const res = await fetch(`${API_BASE}/flashcards/${id}`, { method: 'DELETE' });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to delete flashcard: ${err.message}`);
+ }
+}
+
+// Study sessions
+export async function recordStudySession(date, minutes, topic) {
+ try {
+ const res = await fetch(`${API_BASE}/progress/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ date, minutes, topic }),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to record study session: ${err.message}`);
+ }
+}
+
+export async function getHeatmap(days = 365) {
+ try {
+ const res = await fetch(`${API_BASE}/progress/heatmap?days=${days}`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch heatmap: ${err.message}`);
+ }
+}
+
export async function* streamSSE(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -297,6 +432,40 @@ export async function* streamSSE(response) {
}
}
+export async function backupDatabase() {
+ try {
+ const res = await fetch(`${API_BASE}/config/backup`);
+ if (!res.ok) throw new Error(`Backup failed: ${res.status}`);
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'studyos.db';
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ throw new Error(`Failed to backup database: ${err.message}`);
+ }
+}
+
+export async function restoreDatabase(file) {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch(`${API_BASE}/config/restore`, {
+ method: 'POST',
+ body: formData,
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`Restore failed: ${res.status} ${text}`);
+ }
+ return await res.json();
+ } catch (err) {
+ throw new Error(`Failed to restore database: ${err.message}`);
+ }
+}
+
export async function postChatStream(body, signal) {
const res = await fetch(`${API_BASE}/chat/stream`, {
method: 'POST',
@@ -310,3 +479,150 @@ export async function postChatStream(body, signal) {
}
return res;
}
+
+// Search
+export async function searchApi(q) {
+ try {
+ const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(q)}`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to search: ${err.message}`);
+ }
+}
+
+// Flashcard reviews
+export async function getFlashcardReviews() {
+ try {
+ const res = await fetch(`${API_BASE}/flashcards/reviews/due`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch flashcard reviews: ${err.message}`);
+ }
+}
+
+export async function reviewFlashcard(id, quality) {
+ try {
+ const res = await fetch(`${API_BASE}/flashcards/${id}/review`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ quality }),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to review flashcard: ${err.message}`);
+ }
+}
+
+// Exam PDF
+export async function downloadExamPdf() {
+ try {
+ const res = await fetch(`${API_BASE}/progress/exam/pdf`);
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`PDF download failed: ${res.status} ${text}`);
+ }
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ throw new Error(`Failed to download exam PDF: ${err.message}`);
+ }
+}
+
+// Exam generation + submission
+export async function generateExam(body) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to generate exam: ${err.message}`);
+ }
+}
+
+export async function getExam(id) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/${id}`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch exam: ${err.message}`);
+ }
+}
+
+export async function startExam(id) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/${id}/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to start exam: ${err.message}`);
+ }
+}
+
+export async function submitExam(id, answers) {
+ try {
+ const res = await fetch(`${API_BASE}/exams/${id}/submit`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ answers }),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to submit exam: ${err.message}`);
+ }
+}
+
+// Study buddy share
+export async function shareConversation(id, roleLabel) {
+ try {
+ const res = await fetch(`${API_BASE}/conversations/${id}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role_label: roleLabel }),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to share conversation: ${err.message}`);
+ }
+}
+
+export async function joinConversation(shareToken, userName) {
+ try {
+ const res = await fetch(`${API_BASE}/conversations/join`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ share_token: shareToken, user_name: userName }),
+ });
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to join conversation: ${err.message}`);
+ }
+}
+
+export async function getSharedConversation(token) {
+ try {
+ const res = await fetch(`${API_BASE}/conversations/shared/${token}`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to load shared conversation: ${err.message}`);
+ }
+}
+
+// PDF embeddings (for debugging)
+export async function getPdfEmbeddings(pdfId) {
+ try {
+ const res = await fetch(`${API_BASE}/pdfs/${pdfId}/embeddings`);
+ return await handleResponse(res);
+ } catch (err) {
+ throw new Error(`Failed to fetch PDF embeddings: ${err.message}`);
+ }
+}
diff --git a/client/src/lib/sm2.js b/client/src/lib/sm2.js
new file mode 100644
index 0000000..df108bd
--- /dev/null
+++ b/client/src/lib/sm2.js
@@ -0,0 +1,21 @@
+export function sm2(prev, quality) {
+ let { ease_factor: e, interval_days: i, repetitions: r } = prev;
+ if (quality < 3) {
+ r = 0;
+ i = 1;
+ } else {
+ if (r === 0) i = 1;
+ else if (r === 1) i = 6;
+ else i = Math.round(i * e);
+ r += 1;
+ }
+ e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
+ const next = new Date();
+ next.setDate(next.getDate() + i);
+ return {
+ ease_factor: +e.toFixed(2),
+ interval_days: i,
+ repetitions: r,
+ next_review: next.toISOString().slice(0, 10),
+ };
+}
diff --git a/client/src/lib/useOnlineStatus.js b/client/src/lib/useOnlineStatus.js
new file mode 100644
index 0000000..512e1e9
--- /dev/null
+++ b/client/src/lib/useOnlineStatus.js
@@ -0,0 +1,20 @@
+import { useState, useEffect } from 'react';
+
+export default function useOnlineStatus() {
+ const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
+
+ useEffect(() => {
+ const handleOnline = () => setIsOnline(true);
+ const handleOffline = () => setIsOnline(false);
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ return isOnline;
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index aa84c3a..5b3e12f 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -10,3 +10,19 @@ root.render(
);
+
+// Register PWA service worker in production
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+ import('virtual:pwa-register').then(({ registerSW }) => {
+ registerSW({ immediate: true });
+ }).catch(() => {
+ // silent fail if virtual:pwa-register is unavailable
+ });
+}
+
+window.addEventListener('offline', () => {
+ document.body.setAttribute('data-offline', 'true');
+});
+window.addEventListener('online', () => {
+ document.body.removeAttribute('data-offline');
+});
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
index 8654901..8ff822e 100644
--- a/client/src/pages/Settings.jsx
+++ b/client/src/pages/Settings.jsx
@@ -29,6 +29,9 @@ import {
deletePdf,
getProgress,
resetProgressTopic,
+ backupDatabase,
+ restoreDatabase,
+ downloadExamPdf,
} from '../lib/api';
function ToggleSwitch({ checked, onChange }) {
@@ -84,6 +87,8 @@ export default function Settings() {
// Progress tab
const [progress, setProgress] = useState([]);
+ const [restoreFile, setRestoreFile] = useState(null);
+ const [restoreLoading, setRestoreLoading] = useState(false);
const refreshModels = useCallback(async () => {
try {
@@ -268,6 +273,27 @@ export default function Settings() {
}
};
+ const handleDownloadBackup = async () => {
+ try {
+ await backupDatabase();
+ } catch (err) {
+ alert(err.message);
+ }
+ };
+
+ const handleRestoreBackup = async () => {
+ if (!restoreFile) return;
+ if (!window.confirm('¿Restaurar base de datos? Se reemplazará el estado actual.')) return;
+ setRestoreLoading(true);
+ try {
+ await restoreDatabase(restoreFile);
+ window.location.reload();
+ } catch (err) {
+ alert(err.message);
+ setRestoreLoading(false);
+ }
+ };
+
const progressChartData = progress.map((p) => ({
name: p.topic,
pct: p.percentage,
@@ -311,6 +337,18 @@ export default function Settings() {
>
Progreso
+
setActiveTab('backup')}
+ >
+ Backup
+
+
setActiveTab('datos')}
+ >
+ Datos
+
{/* Tab 1: Modelos */}
@@ -635,6 +673,70 @@ export default function Settings() {