function showToast(text, type='info', duration=4000) { const wrap=document.getElementById('toast-wrap'); const el=document.createElement('div'); const cls={mark:'',note:'success',warn:'warn',reload:'info','mention-t':'mention',success:'success',info:'info',warn:'warn',mention:'mention'}[type]||''; el.className=`toast ${cls}`; el.textContent=text; wrap.appendChild(el); setTimeout(()=>el.remove(),duration); } function playMentionSound() { try { const ctx=new(window.AudioContext||window.webkitAudioContext)(); const o=ctx.createOscillator(),g=ctx.createGain(); o.connect(g);g.connect(ctx.destination); o.type='sine';o.frequency.value=880; g.gain.setValueAtTime(0.25,ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.35); o.start(ctx.currentTime);o.stop(ctx.currentTime+0.35); } catch {} } let sseSource=null; let sseReconnectTimer=null; function startSSE() { if(sseReconnectTimer){clearTimeout(sseReconnectTimer);sseReconnectTimer=null;} if(sseSource){sseSource.close();sseSource=null;} if(!authToken) return; sseSource=new EventSource(`/api/events?token=${encodeURIComponent(authToken)}`); sseSource.addEventListener('mark',e=>{ const d=JSON.parse(e.data); if(!marksCache[d.task_id]) marksCache[d.task_id]={}; marksCache[d.task_id][d.mark_type]={active:d.value,by:d.by,at:d.at}; // Сбрасываем все метки, которые сервер очистил в том же запросе (d.cleared_marks||[]).forEach(m=>{ marksCache[d.task_id][m]={...(marksCache[d.task_id][m]||{}),active:false}; }); // Тост только для действий других пользователей if(d.by!==currentUser?.username){ const markCfg=MARKS_CONFIG.find(m=>m.key===d.mark_type); const label=markCfg?markCfg.sseLabel:d.mark_type; showToast(`👤 ${d.by}: ${d.value?`✓ «${label}»`:`снял «${label}»`} #${taskNumFromId(d.task_id)}`,'info'); } renderTable(); refreshDetailIfOpen(d.task_id); }); sseSource.addEventListener('note',e=>{ const d=JSON.parse(e.data); if(d.text) notesCache[d.task_id]={text:d.text,by:d.by,at:d.at}; else delete notesCache[d.task_id]; // Тост только для действий других пользователей if(d.by!==currentUser?.username){ showToast(`📝 ${d.by}: ${d.text?'обновил заметку':'удалил заметку'} #${taskNumFromId(d.task_id)}`,'success'); } renderTable(); refreshDetailIfOpen(d.task_id); }); sseSource.addEventListener('comment_new',e=>{ const d=JSON.parse(e.data); const tid=String(d.comment.task_id); if(!commentsCache[tid]) commentsCache[tid]=[]; commentsCache[tid].push(d.comment); if(typeof commentCounts==='object') commentCounts[tid]=(commentsCache[tid]||[]).length; showToast(`💬 ${d.comment.author}: комментарий к #${d.task_number}`,'success'); renderTable(); refreshDetailIfOpen(tid); }); sseSource.addEventListener('comment_edit',e=>{ const d=JSON.parse(e.data); const tid=String(d.comment.task_id); if(commentsCache[tid]) commentsCache[tid]=commentsCache[tid].map(c=>c.id===d.comment.id?d.comment:c); refreshDetailIfOpen(tid); }); sseSource.addEventListener('comment_delete',e=>{ const d=JSON.parse(e.data); const tid=String(d.task_id); if(commentsCache[tid]) commentsCache[tid]=commentsCache[tid].filter(c=>c.id!==d.comment_id); if(typeof commentCounts==='object') commentCounts[tid]=(commentsCache[tid]||[]).length; renderTable(); refreshDetailIfOpen(tid); }); sseSource.addEventListener('tasks_updated',e=>{ const d=JSON.parse(e.data); showToast(`🔄 Обновление: ${d.count} заявок`,'info',4000); reloadData(); }); sseSource.addEventListener('mention',e=>{ const d=JSON.parse(e.data); mentionsData.unshift({task_id:d.task_id,mentioned_user:currentUser.username,mentioned_by:d.by,created_at:d.at,read_at:null}); mentionUnread=d.unread_count; renderMentionsBell(); playMentionSound(); showToast(`🔔 ${d.by} упомянул вас в заявке #${d.task_number}`,'mention',8000); }); sseSource.onerror=()=>{ // Браузер сам переподключается при CONNECTING (0/1), нам нужно только при CLOSED (2) if(!sseSource || sseSource.readyState !== EventSource.CLOSED) return; if(sseReconnectTimer) return; sseReconnectTimer=setTimeout(()=>{ sseReconnectTimer=null; if(authToken) startSSE(); },5000); }; } function stopSSE(){ if(sseReconnectTimer){clearTimeout(sseReconnectTimer);sseReconnectTimer=null;} if(sseSource){sseSource.close();sseSource=null;} }