FlowWeb / src /context /AchievementNotificationContext.jsx
danylokhodus's picture
canban
f104d74
Raw
History Blame Contribute Delete
21.5 kB
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>
);
};