Spaces:
Running
Running
| import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; | |
| import axios from 'axios'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { useAuth } from './AuthContext'; | |
| import { | |
| Trophy, | |
| Star, | |
| Flame, | |
| Target, | |
| ShieldCheck, | |
| Zap, | |
| Map as MapIcon | |
| } from 'lucide-react'; | |
| const iconMap = { | |
| Map: MapIcon, | |
| Zap, | |
| Flame, | |
| ShieldCheck, | |
| Trophy, | |
| Target | |
| }; | |
| const AchievementNotificationContext = createContext(); | |
| export const useAchievementNotification = () => useContext(AchievementNotificationContext); | |
| // Web Audio API procedural sound synthesizer for a nostalgic, satisfying chime | |
| const playUnlockSound = () => { | |
| try { | |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const playTone = (freq, startTime, duration) => { | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = 'triangle'; // triangle wave gives a lovely clean bell/retro vibe | |
| osc.frequency.setValueAtTime(freq, startTime); | |
| gain.gain.setValueAtTime(0.12, startTime); | |
| gain.gain.exponentialRampToValueAtTime(0.0001, startTime + duration); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(startTime); | |
| osc.stop(startTime + duration); | |
| }; | |
| const now = audioCtx.currentTime; | |
| // Play a delightful rising major chord arpeggio: C5 -> E5 -> G5 -> C6 | |
| playTone(523.25, now, 0.12); // C5 | |
| playTone(659.25, now + 0.08, 0.12); // E5 | |
| playTone(783.99, now + 0.16, 0.12); // G5 | |
| playTone(1046.50, now + 0.24, 0.35); // C6 | |
| } catch (e) { | |
| console.warn('[Audio] Failed to synthesize unlock chime:', e); | |
| } | |
| }; | |
| const isResetDue = (lastResetTime, rule) => { | |
| if (!lastResetTime) return false; | |
| const lastReset = new Date(lastResetTime); | |
| const now = new Date(); | |
| if (rule?.type === 'daily') { | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| if (lastReset.getTime() < rDate.getTime() && now.getTime() >= rDate.getTime()) { | |
| return true; | |
| } | |
| const tomorrowDate = new Date(rDate); | |
| tomorrowDate.setDate(tomorrowDate.getDate() + 1); | |
| if (lastReset.getTime() < tomorrowDate.getTime() && now.getTime() >= tomorrowDate.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'weekly') { | |
| const dayOfWeek = rule.day !== undefined ? Number(rule.day) : 1; | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| let nextReset = rDate; | |
| while (nextReset.getDay() !== dayOfWeek || nextReset.getTime() <= lastReset.getTime()) { | |
| nextReset.setDate(nextReset.getDate() + 1); | |
| } | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'hourly') { | |
| const hours = Number(rule.hours || 24); | |
| const nextReset = new Date(lastReset.getTime() + hours * 60 * 60 * 1000); | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'minutely') { | |
| const minutes = Number(rule.minutes || 5); | |
| const nextReset = new Date(lastReset.getTime() + minutes * 60 * 1000); | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| const resetDownstreamNodesBackground = (startNodeId, nodes, edges) => { | |
| const visited = new Set(); | |
| const queue = [startNodeId]; | |
| while (queue.length > 0) { | |
| const currentId = queue.shift(); | |
| const outEdges = edges.filter(e => e.source === currentId || e.Source === currentId); | |
| outEdges.forEach(edge => { | |
| const targetId = edge.target || edge.Target; | |
| if (targetId && !visited.has(targetId)) { | |
| visited.add(targetId); | |
| queue.push(targetId); | |
| } | |
| }); | |
| } | |
| return nodes.map(node => { | |
| if (visited.has(node.id || node.Id)) { | |
| const nodeType = (node.type || node.Type || '').toLowerCase(); | |
| if (nodeType === 'sketch' || nodeType === 'habit') { | |
| return { | |
| ...node, | |
| data: { | |
| ...node.data, | |
| status: 'Todo' | |
| } | |
| }; | |
| } | |
| } | |
| return node; | |
| }); | |
| }; | |
| export const AchievementNotificationProvider = ({ children }) => { | |
| const { user } = useAuth(); | |
| const { t } = useTranslation(); | |
| const [activeNotification, setActiveNotification] = useState(null); | |
| const knownUnlockedIds = useRef(new Set()); | |
| const isInitialized = useRef(false); | |
| const knownHabitStatuses = useRef(new Map()); | |
| const isHabitsInitialized = useRef(false); | |
| const lastUserId = useRef(null); | |
| const isCheckingAchievements = useRef(false); | |
| const isCheckingHabits = useRef(false); | |
| const API_URL = import.meta.env.VITE_API_URL; | |
| const fetchCurrentAchievements = async () => { | |
| if (!user) return []; | |
| try { | |
| const response = await axios.get(`${API_URL}/achievements/progress`); | |
| const mapTitleToIcon = (title) => { | |
| if (title.includes('Canvas')) return 'Map'; | |
| if (title.includes('Connect') || title.includes('Decision')) return 'ShieldCheck'; | |
| if (title.includes('Zap')) return 'Zap'; | |
| if (title.includes('Daily')) return 'Flame'; | |
| if (title.includes('Clear Desk')) return 'Target'; | |
| if (title.includes('Portrait')) return 'Camera'; | |
| if (title.includes('Milestone')) return 'Target'; | |
| return 'Trophy'; | |
| }; | |
| const mapCategoryToColor = (category) => { | |
| switch (category) { | |
| case 'Projects': return 'bg-blue-500/20'; | |
| case 'Tasks': return 'bg-yellow-500/20'; | |
| case 'Habits': return 'bg-orange-500/20'; | |
| case 'System': return 'bg-gray-500/20'; | |
| default: return 'bg-green-500/20'; | |
| } | |
| }; | |
| return response.data.map(item => ({ | |
| id: item.achievementId || item.id, | |
| title: item.achievement?.title || 'Unknown Achievement', | |
| desc: item.achievement?.description || '', | |
| icon: item.achievement?.iconUrl || mapTitleToIcon(item.achievement?.title || ''), | |
| unlocked: item.isUnlocked, | |
| color: mapCategoryToColor(item.achievement?.category || ''), | |
| currentValue: item.currentValue, | |
| targetValue: item.achievement?.targetValue || 1 | |
| })); | |
| } catch (e) { | |
| console.error('[Achievements] Error fetching progress:', e); | |
| return []; | |
| } | |
| }; | |
| const getStoredUnlockedIds = (userId) => { | |
| try { | |
| const stored = localStorage.getItem(`unlocked_achievements_${userId}`); | |
| if (stored) { | |
| return new Set(JSON.parse(stored)); | |
| } | |
| } catch (e) { | |
| console.warn('[Achievements] Error reading localStorage:', e); | |
| } | |
| return new Set(); | |
| }; | |
| const saveStoredUnlockedIds = (userId, idsSet) => { | |
| try { | |
| localStorage.setItem(`unlocked_achievements_${userId}`, JSON.stringify(Array.from(idsSet))); | |
| } catch (e) { | |
| console.warn('[Achievements] Error writing to localStorage:', e); | |
| } | |
| }; | |
| const checkAchievements = async () => { | |
| if (!user || isCheckingAchievements.current) return; | |
| isCheckingAchievements.current = true; | |
| try { | |
| const achievements = await fetchCurrentAchievements(); | |
| if (!achievements || achievements.length === 0) return; | |
| const userId = user.id || user.Id; | |
| if (!userId) return; | |
| // Load persistent cache from localStorage if memory is empty | |
| if (knownUnlockedIds.current.size === 0) { | |
| const storedIds = getStoredUnlockedIds(userId); | |
| storedIds.forEach(id => knownUnlockedIds.current.add(id)); | |
| } | |
| // Separate checking based on whether we have completed the initial load | |
| if (!isInitialized.current) { | |
| // First load: just populate the set of already unlocked achievements | |
| achievements.forEach(ach => { | |
| if (ach.unlocked) { | |
| knownUnlockedIds.current.add(ach.id); | |
| } | |
| }); | |
| saveStoredUnlockedIds(userId, knownUnlockedIds.current); | |
| isInitialized.current = true; | |
| console.log('[Achievements] Initialized unlocked cache with:', knownUnlockedIds.current.size, 'achievements.'); | |
| return; | |
| } | |
| // Subsequent loads: check if any achievement is newly unlocked | |
| let cacheUpdated = false; | |
| for (const ach of achievements) { | |
| if (ach.unlocked && !knownUnlockedIds.current.has(ach.id)) { | |
| console.log('[Achievements] Newly Unlocked Achievement:', ach.title); | |
| knownUnlockedIds.current.add(ach.id); | |
| cacheUpdated = true; | |
| // Trigger the beautiful notification! | |
| triggerNotification({ | |
| ...ach, | |
| isAchievement: true | |
| }); | |
| } | |
| } | |
| if (cacheUpdated) { | |
| saveStoredUnlockedIds(userId, knownUnlockedIds.current); | |
| } | |
| } finally { | |
| isCheckingAchievements.current = false; | |
| } | |
| }; | |
| const checkHabitCooldowns = async () => { | |
| if (!user || isCheckingHabits.current) return; | |
| isCheckingHabits.current = true; | |
| try { | |
| const graphsRes = await axios.get(`${API_URL}/graphs/user/${user.id}`); | |
| const graphs = graphsRes.data; | |
| let newlyRechargedHabits = []; | |
| for (const g of graphs) { | |
| try { | |
| const graphRes = await axios.get(`${API_URL}/graphs/${g.id}/full`); | |
| const originalNodes = graphRes.data?.nodes || []; | |
| const edges = graphRes.data?.edges || []; | |
| let hasChanges = false; | |
| let updatedNodes = [...originalNodes]; | |
| for (const node of originalNodes) { | |
| const nodeType = (node.type || node.Type || '').toLowerCase(); | |
| if (nodeType === 'habit') { | |
| const nodeId = node.id; | |
| const currentStatus = node.data?.status || 'Pending'; | |
| if (!knownHabitStatuses.current.has(nodeId)) { | |
| knownHabitStatuses.current.set(nodeId, currentStatus); | |
| if (currentStatus === 'Done') { | |
| const lastResetTime = node.data.lastResetTime || node.data.createdAt || new Date().toISOString(); | |
| const rule = node.data.resetRule || { type: 'daily', time: '00:00' }; | |
| if (isResetDue(lastResetTime, rule)) { | |
| console.log(`[HabitTrackerBackground] Initial check found habit due: "${node.data.label}"`); | |
| hasChanges = true; | |
| updatedNodes = updatedNodes.map(n => { | |
| if (n.id === nodeId) { | |
| return { | |
| ...n, | |
| data: { | |
| ...n.data, | |
| status: 'Todo', | |
| lastResetTime: new Date().toISOString() | |
| } | |
| }; | |
| } | |
| return n; | |
| }); | |
| updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges); | |
| knownHabitStatuses.current.set(nodeId, 'Todo'); | |
| newlyRechargedHabits.push(node.data?.label || t('canvas.habit')); | |
| } | |
| } | |
| continue; | |
| } | |
| const prevStatus = knownHabitStatuses.current.get(nodeId); | |
| if (currentStatus === 'Done') { | |
| const lastResetTime = node.data.lastResetTime || node.data.createdAt || new Date().toISOString(); | |
| const rule = node.data.resetRule || { type: 'daily', time: '00:00' }; | |
| if (isResetDue(lastResetTime, rule)) { | |
| console.log(`[HabitTrackerBackground] Resetting habit: "${node.data.label}"`); | |
| hasChanges = true; | |
| // 1. Reset target node | |
| updatedNodes = updatedNodes.map(n => { | |
| if (n.id === nodeId) { | |
| return { | |
| ...n, | |
| data: { | |
| ...n.data, | |
| status: 'Todo', | |
| lastResetTime: new Date().toISOString() | |
| } | |
| }; | |
| } | |
| return n; | |
| }); | |
| // 2. Reset downstream nodes | |
| updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges); | |
| knownHabitStatuses.current.set(nodeId, 'Todo'); | |
| newlyRechargedHabits.push(node.data?.label || t('canvas.habit')); | |
| } else { | |
| knownHabitStatuses.current.set(nodeId, 'Done'); | |
| } | |
| } else if (currentStatus === 'Pending' || currentStatus === 'Todo') { | |
| if (prevStatus === 'Done') { | |
| console.log(`[HabitTrackerBackground] Habit transitioned to Todo/Pending externally: "${node.data.label}"`); | |
| newlyRechargedHabits.push(node.data?.label || t('canvas.habit')); | |
| } | |
| knownHabitStatuses.current.set(nodeId, currentStatus); | |
| } | |
| } | |
| } | |
| if (hasChanges) { | |
| const payload = { | |
| nodes: updatedNodes, | |
| edges: edges | |
| }; | |
| await axios.post(`${API_URL}/graphs/${g.id}/full`, payload); | |
| console.log(`[HabitTrackerBackground] Successfully persisted reset status for graph ${g.id}`); | |
| } | |
| } catch (e) { | |
| console.warn(`[HabitsBackground] Error checking/resetting graph ${g.id}:`, e); | |
| } | |
| } | |
| for (const name of newlyRechargedHabits) { | |
| triggerNotification({ | |
| id: 'habit-recharge-' + Date.now() + '-' + Math.random(), | |
| icon: 'Flame', | |
| title: t('habits.recharge_notification_title', 'Habit Recharged! ⚡'), | |
| desc: t('habits.recharge_notification_desc', `Habit "${name}" is ready to be performed again!`, { name }), | |
| color: 'bg-orange-500/20', | |
| headerText: t('habits.recharge_notification_header', 'ЗВИЧКА ГОТОВА') | |
| }); | |
| } | |
| } catch (e) { | |
| console.error('[HabitsBackground] Error fetching cooldown info:', e); | |
| } finally { | |
| isCheckingHabits.current = false; | |
| } | |
| }; | |
| const triggerNotification = (achievement) => { | |
| playUnlockSound(); | |
| setActiveNotification(achievement); | |
| const desktopNotifEnabled = localStorage.getItem('desktop_notifications') === 'true'; | |
| if (desktopNotifEnabled && 'Notification' in window && Notification.permission === 'granted') { | |
| try { | |
| const titleText = achievement.isAchievement | |
| ? `🏆 ${t('achievements.unlocked_header', 'Achievement Unlocked!')}: ${achievement.title}` | |
| : `${achievement.title}`; | |
| new Notification(titleText, { | |
| body: achievement.desc, | |
| icon: '/logo.png' | |
| }); | |
| } catch (e) { | |
| console.warn('[Notifications] Failed to display native desktop notification:', e); | |
| } | |
| } | |
| // Auto-clear after 6 seconds | |
| setTimeout(() => { | |
| setActiveNotification(null); | |
| }, 6000); | |
| }; | |
| // Reset cache only if user logs out or switches to a different user account | |
| useEffect(() => { | |
| if (!user) { | |
| isInitialized.current = false; | |
| knownUnlockedIds.current.clear(); | |
| knownHabitStatuses.current.clear(); | |
| isHabitsInitialized.current = false; | |
| lastUserId.current = null; | |
| return; | |
| } | |
| if (user.id !== lastUserId.current) { | |
| console.log('[Achievements] User changed. Resetting unlocked achievements cache.'); | |
| isInitialized.current = false; | |
| knownUnlockedIds.current.clear(); | |
| knownHabitStatuses.current.clear(); | |
| isHabitsInitialized.current = false; | |
| lastUserId.current = user.id; | |
| checkAchievements(); | |
| checkHabitCooldowns(); | |
| } else { | |
| // Same user updated (e.g. profile updated). Keep cache, just check for new achievements! | |
| checkAchievements(); | |
| checkHabitCooldowns(); | |
| } | |
| }, [user]); | |
| // Periodic polling check every 12 seconds | |
| useEffect(() => { | |
| if (!user) return; | |
| const interval = setInterval(() => { | |
| checkAchievements(); | |
| checkHabitCooldowns(); | |
| }, 12000); | |
| return () => clearInterval(interval); | |
| }, [user]); | |
| return ( | |
| <AchievementNotificationContext.Provider value={{ checkAchievements, triggerNotification }}> | |
| {children} | |
| {/* Dynamic Overlay Achievement Notification Toast */} | |
| <AnimatePresence> | |
| {activeNotification && ( | |
| <AchievementToast | |
| achievement={activeNotification} | |
| onClose={() => setActiveNotification(null)} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| </AchievementNotificationContext.Provider> | |
| ); | |
| }; | |
| // High-fidelity, sketchy hand-drawn animated toast with procedural paper particles | |
| const AchievementToast = ({ achievement, onClose }) => { | |
| const { t } = useTranslation(); | |
| const Icon = iconMap[achievement.icon] || Star; | |
| // Pre-generate 30 vector particles for a sketchy paper-confetti explosion | |
| const confettiParticles = Array.from({ length: 30 }).map((_, i) => { | |
| const angle = Math.random() * 360; | |
| const distance = 90 + Math.random() * 130; | |
| return { | |
| id: i, | |
| angle, | |
| distance, | |
| size: 6 + Math.random() * 8, | |
| color: ['#F59E0B', '#10B981', '#3B82F6', '#EF4444', '#EC4899', '#8B5CF6'][Math.floor(Math.random() * 6)], | |
| }; | |
| }); | |
| return ( | |
| <div className="fixed bottom-8 right-8 z-[9999] pointer-events-none flex flex-col items-center"> | |
| {/* 1. Exploding Paper Confetti */} | |
| {confettiParticles.map((p) => { | |
| const radian = (p.angle * Math.PI) / 180; | |
| const targetX = Math.cos(radian) * p.distance; | |
| const targetY = Math.sin(radian) * p.distance; | |
| return ( | |
| <motion.div | |
| key={p.id} | |
| className="absolute rounded-sm pointer-events-none" | |
| style={{ | |
| backgroundColor: p.color, | |
| width: p.size, | |
| height: p.size, | |
| left: '50%', | |
| top: '50%', | |
| marginLeft: -p.size / 2, | |
| marginTop: -p.size / 2, | |
| }} | |
| initial={{ x: 0, y: 0, scale: 0, rotate: 0, opacity: 1 }} | |
| animate={{ | |
| x: targetX, | |
| y: targetY - 30, | |
| scale: [0, 1.3, 0.9, 0], | |
| rotate: Math.random() * 720 - 360, | |
| opacity: [1, 1, 0] | |
| }} | |
| transition={{ | |
| duration: 1.8, | |
| ease: [0.1, 0.8, 0.3, 1] | |
| }} | |
| /> | |
| ); | |
| })} | |
| {/* 2. Main Toast Body */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 50, scale: 0.8, rotate: -3 }} | |
| animate={{ opacity: 1, y: 0, scale: 1, rotate: 0 }} | |
| exit={{ opacity: 0, y: 20, scale: 0.8, rotate: 3 }} | |
| transition={{ type: "spring", stiffness: 180, damping: 15 }} | |
| className="pointer-events-auto bg-surface-container-lowest p-6 rough-border border-4 border-primary shadow-[8px_8px_0px_0px_var(--color-primary)] flex items-center gap-6 max-w-md w-full relative overflow-hidden" | |
| > | |
| {/* Animated highlighter splash background */} | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: "100%" }} | |
| transition={{ delay: 0.4, duration: 0.6, ease: "easeOut" }} | |
| className="absolute inset-y-0 left-0 bg-secondary/10 -z-10 pointer-events-none" | |
| /> | |
| {/* Unlocked Icon Box with wobble */} | |
| <motion.div | |
| animate={{ rotate: [0, -10, 10, -10, 10, 0] }} | |
| transition={{ delay: 0.5, duration: 0.8, ease: "easeInOut" }} | |
| className={`w-16 h-16 rounded-full flex items-center justify-center ${achievement.color || 'bg-secondary/20'} border-2 border-primary shadow-sm flex-shrink-0 relative`} | |
| > | |
| <Icon size={32} className="text-primary animate-pulse" /> | |
| <div className="absolute -top-1 -right-1 bg-secondary text-primary rounded-full p-1 border border-primary"> | |
| <Star size={10} fill="currentColor" /> | |
| </div> | |
| </motion.div> | |
| {/* Description & Congratulations */} | |
| <div className="flex-grow flex flex-col gap-1"> | |
| <div className="text-[10px] font-bold uppercase tracking-[0.2em] text-secondary font-display-lg"> | |
| {achievement.isAchievement | |
| ? t('achievements.unlocked_header', 'Achievement Unlocked!') | |
| : (achievement.headerText || t('achievements.notification_header', 'Notification'))} | |
| </div> | |
| <h4 className="text-2xl font-display-lg text-primary leading-tight"> | |
| {achievement.title} | |
| </h4> | |
| <p className="text-xs font-bold text-on-surface-variant/80 font-accent-note leading-relaxed"> | |
| {achievement.desc} | |
| </p> | |
| </div> | |
| {/* Close Button */} | |
| <button | |
| onClick={onClose} | |
| className="p-1 hover:bg-primary/5 rounded-full self-start hover:scale-110 transition-transform text-on-surface-variant hover:text-primary" | |
| > | |
| <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </motion.div> | |
| </div> | |
| ); | |
| }; | |