diff --git a/apps/studymesh/src/App.tsx b/apps/studymesh/src/App.tsx index bbd446d5..2f8d4578 100644 --- a/apps/studymesh/src/App.tsx +++ b/apps/studymesh/src/App.tsx @@ -18,6 +18,7 @@ import WorkspaceStudioShell from './components/workspace/WorkspaceStudioShell' import DashboardProvider from './components/Dasboard/DashboardProvider' import LayoutProvider from './components/Layout/LayoutProvider' import StudyMeshLanding from './components/landing/StudyMeshLanding' +import PomodoroFAB from './PomodoroFAB' import { useWorkspaceActions } from './customHooks/useWorkspaceActions' import LocalAiDebugPanel from './components/debug/LocalAiDebugPanel' import { cancelAllLocalAiSessions } from './studyPack/ai' @@ -134,6 +135,7 @@ const WorkspacePage = () => { + ) diff --git a/apps/studymesh/src/PomodoroFAB.tsx b/apps/studymesh/src/PomodoroFAB.tsx new file mode 100644 index 00000000..710ab846 --- /dev/null +++ b/apps/studymesh/src/PomodoroFAB.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react' +import { Box, Fab, Tooltip } from '@mui/material' +import { PomodoroTimer } from './components/pomodoro' + +const PomodoroFAB: React.FC = () => { + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + + + setIsOpen(true)} + sx={{ + width: 56, + height: 56, + fontSize: '1.5rem', + bgcolor: 'primary.main', + '&:hover': { bgcolor: 'primary.dark' }, + }} + > + ⚑ + + + + + {isOpen && ( + <> + setIsOpen(false)} + sx={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + bgcolor: 'rgba(0,0,0,0.3)', + zIndex: 9998, + }} + /> + e.stopPropagation()}> + setIsOpen(false)} /> + + + )} + + ) +} + +export default PomodoroFAB diff --git a/apps/studymesh/src/auth/AuthProvider.tsx b/apps/studymesh/src/auth/AuthProvider.tsx index 6865852f..6fa2e4d4 100644 --- a/apps/studymesh/src/auth/AuthProvider.tsx +++ b/apps/studymesh/src/auth/AuthProvider.tsx @@ -228,6 +228,11 @@ export const RequireAuth = ({ children }: { children: React.ReactNode }) => { const { user, loading } = useAuth() const location = useLocation() + // πŸ”“ DEV BYPASS: Skip login in development/testing + if (localStorage.getItem('dev_bypass_auth') === 'true') { + return <>{children} + } + if (loading) { return ( { } const openWorkspace = (action?: string) => { + // πŸ”“ DEV BYPASS: Enable bypass when clicking "Try StudyMesh" + localStorage.setItem('dev_bypass_auth', 'true') navigate(action ? `/workspace?action=${action}` : '/workspace') } diff --git a/apps/studymesh/src/components/pomodoro/PomodoroTimer.tsx b/apps/studymesh/src/components/pomodoro/PomodoroTimer.tsx new file mode 100644 index 00000000..7fc41f51 --- /dev/null +++ b/apps/studymesh/src/components/pomodoro/PomodoroTimer.tsx @@ -0,0 +1,1053 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { + Box, + Typography, + Paper, + Button, + IconButton, + Chip, + Slider, + Switch, + FormControlLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + ToggleButtonGroup, + ToggleButton, + LinearProgress, + Divider, + List, + ListItem, + ListItemText, + ListItemIcon, + ListItemSecondaryAction, + Menu, + MenuItem, + Collapse, + Tooltip, +} from '@mui/material' +import { + PlayArrow as PlayIcon, + Pause as PauseIcon, + Refresh as ResetIcon, + SkipNext as SkipIcon, + Settings as SettingsIcon, + Close as CloseIcon, + Timer as TimerIcon, + Coffee as BreakIcon, + LocalFireDepartment as StreakIcon, + TrendingUp as StatsIcon, + VolumeUp as SoundIcon, + Notifications as NotifyIcon, + CheckCircle as DoneIcon, + Delete as DeleteIcon, + MoreVert as MoreIcon, +} from '@mui/icons-material' +import { alpha } from '@mui/material/styles' + +// ============================================================================ +// Types +// ============================================================================ + +export type TimerPhase = 'work' | 'shortBreak' | 'longBreak' | 'idle' + +export interface PomodoroConfig { + workDuration: number // minutes + shortBreakDuration: number + longBreakDuration: number + pomodorosUntilLongBreak: number // default 4 + autoStartBreaks: boolean + autoStartWork: boolean + soundEnabled: boolean + notificationEnabled: boolean + keepAwake: boolean +} + +export interface PomodoroSession { + id: string + type: 'work' | 'break' + duration: number // actual minutes + completedAt: Date + interrupted: boolean +} + +export interface PomodoroStats { + totalPomodoros: number + totalWorkMinutes: number + totalBreakMinutes: number + currentStreak: number + longestStreak: number + averagePerDay: number + sessionsToday: number +} + +export interface PomodoroState { + phase: TimerPhase + timeRemaining: number // seconds + isRunning: boolean + pomodoroCount: number // completed in current cycle + totalPomodoros: number // all-time today + sessions: PomodoroSession[] +} + +// ============================================================================ +// Constants +// ============================================================================ + +const defaultConfig: PomodoroConfig = { + workDuration: 25, + shortBreakDuration: 5, + longBreakDuration: 15, + pomodorosUntilLongBreak: 4, + autoStartBreaks: false, + autoStartWork: false, + soundEnabled: true, + notificationEnabled: true, + keepAwake: false, +} + +const SOUND_URLS = { + workEnd: 'https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3', + breakEnd: 'https://assets.mixkit.co/active_storage/sfx/2868/2868-preview.mp3', + tick: 'https://assets.mixkit.co/active_storage/sfx/125/125-preview.mp3', +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function generateId(): string { + return `pomodoro-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` +} + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` +} + +function getPhaseColor(phase: TimerPhase): string { + switch (phase) { + case 'work': return '#F44336' + case 'shortBreak': return '#4CAF50' + case 'longBreak': return '#2196F3' + default: return '#9E9E9E' + } +} + +function getPhaseLabel(phase: TimerPhase): string { + switch (phase) { + case 'work': return 'πŸ… Focus Time' + case 'shortBreak': return 'β˜• Short Break' + case 'longBreak': return 'πŸ›‹οΈ Long Break' + default: return '⏸️ Ready' + } +} + +// ============================================================================ +// Circular Progress Timer +// ============================================================================ + +interface CircularTimerProps { + timeRemaining: number + totalTime: number + phase: TimerPhase + size?: number +} + +const CircularTimer: React.FC = ({ + timeRemaining, + totalTime, + phase, + size = 280, +}) => { + const radius = (size - 20) / 2 + const circumference = 2 * Math.PI * radius + const progress = timeRemaining / totalTime + const strokeDashoffset = circumference * (1 - progress) + const color = getPhaseColor(phase) + + return ( + + {/* Background circle */} + + + {/* Progress arc */} + + + + {/* Timer display */} + + + {formatTime(timeRemaining)} + + + {getPhaseLabel(phase)} + + + + ) +} + +// ============================================================================ +// Session History Item +// ============================================================================ + +interface SessionItemProps { + session: PomodoroSession + onDelete?: () => void +} + +const SessionItem: React.FC = ({ session, onDelete }) => { + return ( + + + {session.type === 'work' ? 'πŸ…' : 'β˜•'} + + + {session.interrupted && ( + + )} + {onDelete && ( + + + + + + )} + + ) +} + +// ============================================================================ +// Statistics View +// ============================================================================ + +interface StatsViewProps { + sessions: PomodoroSession[] + config: PomodoroConfig +} + +const StatsView: React.FC = ({ sessions, config }) => { + const stats = useMemo(() => { + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + + const todaySessions = sessions.filter((s) => s.completedAt >= todayStart && !s.interrupted) + const workSessions = todaySessions.filter((s) => s.type === 'work') + + const totalWorkMinutes = workSessions.reduce((sum, s) => sum + s.duration, 0) + const totalBreakMinutes = todaySessions.filter((s) => s.type !== 'work').reduce((sum, s) => sum + s.duration, 0) + + // Calculate streak (consecutive pomodoros) + const sortedSessions = [...sessions].sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()) + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + + for (const session of sortedSessions) { + if (session.type === 'work' && !session.interrupted) { + tempStreak++ + currentStreak = tempStreak + } else if (session.type !== 'work') { + longestStreak = Math.max(longestStreak, tempStreak) + tempStreak = 0 + } + } + longestStreak = Math.max(longestStreak, tempStreak) + + return { + totalPomodoros: workSessions.length, + totalWorkMinutes, + totalBreakMinutes, + currentStreak, + longestStreak, + averagePerDay: workSessions.length, + sessionsToday: workSessions.length, + } + }, [sessions]) + + return ( + + + πŸ“Š Today's Statistics + + + + + + {stats.totalPomodoros} + + + πŸ… Pomodoros + + + + + + {Math.floor(stats.totalWorkMinutes / 60)}h {stats.totalWorkMinutes % 60}m + + + ⏱️ Focus Time + + + + + + {stats.sessionsToday} + + + πŸ“… Sessions Today + + + + + + + + + Current Streak + + + {stats.currentStreak} πŸ… + + + + + + + Longest Streak + + + {stats.longestStreak} πŸ… + + + + + + + + πŸ“… Session History + + + {sessions.length === 0 ? ( + + No sessions recorded yet. Start your first pomodoro! + + ) : ( + sessions.slice(-10).reverse().map((session) => ( + + )) + )} + + + ) +} + +// ============================================================================ +// Settings Dialog +// ============================================================================ + +interface SettingsDialogProps { + open: boolean + config: PomodoroConfig + onChange: (config: PomodoroConfig) => void + onClose: () => void +} + +const SettingsDialog: React.FC = ({ open, config, onChange, onClose }) => { + const [localConfig, setLocalConfig] = useState(config) + + const handleSave = () => { + onChange(localConfig) + onClose() + } + + const updateConfig = (key: keyof PomodoroConfig, value: any) => { + setLocalConfig((prev) => ({ ...prev, [key]: value })) + } + + return ( + + βš™οΈ Pomodoro Settings + + + {/* Durations */} + + Timer Durations (minutes) + + + + + Focus Duration: {localConfig.workDuration} + + updateConfig('workDuration', v)} + marks={[ + { value: 15, label: '15m' }, + { value: 25, label: '25m' }, + { value: 45, label: '45m' }, + ]} + /> + + + + + Short Break: {localConfig.shortBreakDuration} + + updateConfig('shortBreakDuration', v)} + /> + + + + + Long Break: {localConfig.longBreakDuration} + + updateConfig('longBreakDuration', v)} + /> + + + + + Pomodoros until long break: {localConfig.pomodorosUntilLongBreak} + + updateConfig('pomodorosUntilLongBreak', v)} + marks={[ + { value: 2, label: '2' }, + { value: 4, label: '4' }, + { value: 6, label: '6' }, + ]} + /> + + + + + {/* Options */} + + Automation + + + updateConfig('autoStartBreaks', e.target.checked)} + /> + } + label="Auto-start breaks" + /> + + updateConfig('autoStartWork', e.target.checked)} + /> + } + label="Auto-start work sessions" + /> + + + + {/* Notifications */} + + Notifications + + + updateConfig('soundEnabled', e.target.checked)} + /> + } + label="Sound alerts" + /> + + updateConfig('notificationEnabled', e.target.checked)} + /> + } + label="Browser notifications" + /> + + + + + + + + ) +} + +// ============================================================================ +// Main Pomodoro Timer Component +// ============================================================================ + +interface PomodoroTimerProps { + onComplete?: (session: PomodoroSession) => void + onClose?: () => void +} + +const PomodoroTimer: React.FC = ({ onComplete, onClose }) => { + const [config, setConfig] = useState(defaultConfig) + const [state, setState] = useState({ + phase: 'idle', + timeRemaining: config.workDuration * 60, + isRunning: false, + pomodoroCount: 0, + totalPomodoros: 0, + sessions: [], + }) + const [showSettings, setShowSettings] = useState(false) + const [showStats, setShowStats] = useState(false) + const [elapsedTime, setElapsedTime] = useState(0) // for current session + const [showMenu, setShowMenu] = useState(false) + const [menuAnchor, setMenuAnchor] = useState(null) + + const intervalRef = useRef(null) + const audioRef = useRef(null) + + // Load sessions from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem('studymesh-pomodoro-sessions') + if (stored) { + const sessions = JSON.parse(stored).map((s: any) => ({ + ...s, + completedAt: new Date(s.completedAt), + })) + setState((prev) => ({ ...prev, sessions })) + } + } catch (e) { + console.error('Failed to load pomodoro sessions:', e) + } + }, []) + + // Save sessions to localStorage + useEffect(() => { + localStorage.setItem('studymesh-pomodoro-sessions', JSON.stringify(state.sessions)) + }, [state.sessions]) + + // Play sound helper + const playSound = useCallback((url: string) => { + if (config.soundEnabled && audioRef.current) { + audioRef.current.src = url + audioRef.current.play().catch(() => {}) + } + }, [config.soundEnabled]) + + // Send notification + const sendNotification = useCallback((title: string, body: string) => { + if (config.notificationEnabled && 'Notification' in window) { + if (Notification.permission === 'granted') { + new Notification(title, { body, icon: 'πŸ…' }) + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + new Notification(title, { body, icon: 'πŸ…' }) + } + }) + } + } + }, [config.notificationEnabled]) + + // Timer tick + useEffect(() => { + if (state.isRunning && state.timeRemaining > 0) { + intervalRef.current = setInterval(() => { + setState((prev) => { + const newTime = prev.timeRemaining - 1 + if (newTime <= 0) { + // Timer completed + return prev + } + return { ...prev, timeRemaining: newTime } + }) + setElapsedTime((e) => e + 1) + }, 1000) + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, [state.isRunning, state.timeRemaining]) + + // Handle timer completion + useEffect(() => { + if (state.timeRemaining === 0 && state.phase !== 'idle') { + // Record session + const completedSession: PomodoroSession = { + id: generateId(), + type: state.phase === 'work' ? 'work' : 'break', + duration: state.phase === 'work' ? config.workDuration : + state.phase === 'shortBreak' ? config.shortBreakDuration : + config.longBreakDuration, + completedAt: new Date(), + interrupted: false, + } + + setState((prev) => ({ + ...prev, + sessions: [completedSession, ...prev.sessions], + totalPomodoros: prev.phase === 'work' ? prev.totalPomodoros + 1 : prev.totalPomodoros, + pomodoroCount: prev.phase === 'work' ? + (prev.pomodoroCount + 1) % config.pomodorosUntilLongBreak : + prev.pomodoroCount, + })) + + onComplete?.(completedSession) + + // Notifications + if (state.phase === 'work') { + playSound(SOUND_URLS.workEnd) + sendNotification('πŸ… Pomodoro Complete!', 'Time for a break!') + } else { + playSound(SOUND_URLS.breakEnd) + sendNotification('β˜• Break Over!', 'Ready to focus again?') + } + + // Determine next phase + if (state.phase === 'work') { + const nextPhase = (state.pomodoroCount + 1) >= config.pomodorosUntilLongBreak ? 'longBreak' : 'shortBreak' + const nextDuration = nextPhase === 'longBreak' ? config.longBreakDuration : config.shortBreakDuration + setState((prev) => ({ + ...prev, + phase: nextPhase, + timeRemaining: nextDuration * 60, + isRunning: config.autoStartBreaks, + })) + } else { + setState((prev) => ({ + ...prev, + phase: 'work', + timeRemaining: config.workDuration * 60, + isRunning: config.autoStartWork, + })) + } + setElapsedTime(0) + } + }, [state.timeRemaining, state.phase]) + + const handleStart = useCallback(() => { + if (state.phase === 'idle') { + setState((prev) => ({ + ...prev, + phase: 'work', + timeRemaining: config.workDuration * 60, + isRunning: true, + })) + } else { + setState((prev) => ({ ...prev, isRunning: true })) + } + }, [state.phase, config.workDuration]) + + const handlePause = useCallback(() => { + setState((prev) => ({ ...prev, isRunning: false })) + }, []) + + const handleReset = useCallback(() => { + const duration = state.phase === 'work' ? config.workDuration : + state.phase === 'shortBreak' ? config.shortBreakDuration : + state.phase === 'longBreak' ? config.longBreakDuration : + config.workDuration + setState((prev) => ({ + ...prev, + timeRemaining: duration * 60, + isRunning: false, + })) + setElapsedTime(0) + }, [state.phase, config]) + + const handleSkip = useCallback(() => { + // Record as interrupted + const interruptedSession: PomodoroSession = { + id: generateId(), + type: state.phase === 'work' ? 'work' : 'break', + duration: Math.floor(elapsedTime / 60), + completedAt: new Date(), + interrupted: true, + } + + setState((prev) => ({ + ...prev, + sessions: [interruptedSession, ...prev.sessions], + })) + + // Move to next phase + if (state.phase === 'work') { + const nextPhase = (state.pomodoroCount + 1) >= config.pomodorosUntilLongBreak ? 'longBreak' : 'shortBreak' + const nextDuration = nextPhase === 'longBreak' ? config.longBreakDuration : config.shortBreakDuration + setState((prev) => ({ + ...prev, + phase: nextPhase, + timeRemaining: nextDuration * 60, + isRunning: false, + })) + } else { + setState((prev) => ({ + ...prev, + phase: 'work', + timeRemaining: config.workDuration * 60, + isRunning: false, + })) + } + setElapsedTime(0) + }, [state.phase, state.pomodoroCount, config, elapsedTime]) + + const handleConfigChange = useCallback((newConfig: PomodoroConfig) => { + setConfig(newConfig) + // Update time remaining if idle + if (state.phase === 'idle') { + setState((prev) => ({ ...prev, timeRemaining: newConfig.workDuration * 60 })) + } + }, [state.phase]) + + const getTotalTime = () => { + if (state.phase === 'work') return config.workDuration * 60 + if (state.phase === 'shortBreak') return config.shortBreakDuration * 60 + if (state.phase === 'longBreak') return config.longBreakDuration * 60 + return config.workDuration * 60 + } + + const phaseColor = getPhaseColor(state.phase) + + return ( + + {/* Hidden audio for sounds */} + + ) +} + +export default PomodoroTimer + +// ============================================================================ +// Hook for Pomodoro Timer +// ============================================================================ + +export function usePomodoro() { + const [isOpen, setIsOpen] = useState(false) + const [sessions, setSessions] = useState([]) + + // Load from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem('studymesh-pomodoro-sessions') + if (stored) { + const parsed = JSON.parse(stored).map((s: any) => ({ + ...s, + completedAt: new Date(s.completedAt), + })) + setSessions(parsed) + } + } catch (e) { + console.error('Failed to load sessions:', e) + } + }, []) + + const open = useCallback(() => setIsOpen(true), []) + const close = useCallback(() => setIsOpen(false), []) + + const addSession = useCallback((session: PomodoroSession) => { + setSessions((prev) => { + const next = [session, ...prev].slice(0, 100) + localStorage.setItem('studymesh-pomodoro-sessions', JSON.stringify(next)) + return next + }) + }, []) + + const stats = useMemo(() => { + const today = new Date() + today.setHours(0, 0, 0, 0) + const todaySessions = sessions.filter((s) => s.completedAt >= today && !s.interrupted) + const workSessions = todaySessions.filter((s) => s.type === 'work') + + return { + totalPomodoros: workSessions.length, + totalWorkMinutes: workSessions.reduce((sum, s) => sum + s.duration, 0), + currentStreak: workSessions.length, + } + }, [sessions]) + + return { + isOpen, + sessions, + stats, + open, + close, + addSession, + PomodoroTimer: PomodoroTimer as React.FC<{ + onComplete?: (session: PomodoroSession) => void + onClose?: () => void + }>, + } +} \ No newline at end of file diff --git a/apps/studymesh/src/components/pomodoro/index.ts b/apps/studymesh/src/components/pomodoro/index.ts new file mode 100644 index 00000000..e09a05cc --- /dev/null +++ b/apps/studymesh/src/components/pomodoro/index.ts @@ -0,0 +1,9 @@ +export { default as PomodoroTimer } from './PomodoroTimer' +export { usePomodoro } from './PomodoroTimer' +export type { + PomodoroConfig, + PomodoroSession, + PomodoroStats, + PomodoroState, + TimerPhase, +} from './PomodoroTimer' \ No newline at end of file