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 ( {children} {/* Dynamic Overlay Achievement Notification Toast */} {activeNotification && ( setActiveNotification(null)} /> )} ); }; // 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 (
{/* 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 ( ); })} {/* 2. Main Toast Body */} {/* Animated highlighter splash background */} {/* Unlocked Icon Box with wobble */}
{/* Description & Congratulations */}
{achievement.isAchievement ? t('achievements.unlocked_header', 'Achievement Unlocked!') : (achievement.headerText || t('achievements.notification_header', 'Notification'))}

{achievement.title}

{achievement.desc}

{/* Close Button */}
); };