/* ===== Семейный таск-менеджер (чистая статика, localStorage) ===== */ const KEY = 'family_tasks_v1'; const SESSION = 'family_tasks_user'; const AVATAR_COLORS = ['#ec4899','#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4','#84cc16','#f97316','#6366f1']; const AVATAR_EMOJIS = ['👩','👨','👧','👦','🧑','👵','👴','🧒','👶','🧔','👩‍🦰','👨‍🦱']; let data = null; // {familyName, members:[], tasks:[]} let me = null; // текущий пользователь (member id) let view = 'mine'; let editingId = null; // id задачи в модалке (null = создание) let postponeId = null; // id задачи для переноса /* ---------- storage ---------- */ function load(){ try{ return JSON.parse(localStorage.getItem(KEY)); }catch(e){ return null; } } function save(){ localStorage.setItem(KEY, JSON.stringify(data)); } function uid(){ return 'id'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); } /* ---------- helpers ---------- */ const $ = s => document.querySelector(s); const $$ = s => [...document.querySelectorAll(s)]; function member(id){ return (data.members||[]).find(m=>m.id===id) || null; } function escapeHtml(s){ return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function todayStr(){ const d=new Date(); return fmt(d); } function fmt(d){ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } function parseDate(s){ if(!s) return null; const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); } function prettyDate(s){ const d=parseDate(s); if(!d) return '—'; return d.toLocaleDateString('ru-RU',{day:'numeric',month:'short'}); } function prettyDateTime(s,t){ let r=prettyDate(s); if(t) r+=' · '+t; return r; } function relativeDue(s){ if(!s) return ''; const d=parseDate(s); const now=new Date(); now.setHours(0,0,0,0); const diff=Math.round((d-now)/86400000); if(diff<0) return 'просрочено'; if(diff===0) return 'сегодня'; if(diff===1) return 'завтра'; if(diff<7) return `через ${diff} дн.`; return prettyDate(s); } function initials(name){ return (name||'?').trim().slice(0,1).toUpperCase(); } function colorFor(id){ const m=member(id); return m?m.color:'#94a3b8'; } function avatarHtml(m, size){ if(!m) return `?`; const content = m.emoji || initials(m.name); return `${content}`; } function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.remove('hidden'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.add('hidden'),2200); } const STATUS = { todo: {label:'В работе', cls:'bg-amber-100 text-amber-700'}, done: {label:'Готово', cls:'bg-emerald-100 text-emerald-700'}, postponed: {label:'Перенесено',cls:'bg-sky-100 text-sky-700'}, cancelled: {label:'Отменено', cls:'bg-gray-200 text-gray-600'}, }; const PRIORITY = { low: {label:'🟢 Низкая', cls:'bg-gray-100 text-gray-600'}, medium: {label:'🟡 Средняя', cls:'bg-amber-50 text-amber-600'}, high: {label:'🔴 Высокая', cls:'bg-red-50 text-red-600'}, }; /* ============================================================ ИНИЦИАЛИЗАЦИЯ ============================================================ */ function init(){ data = load(); if(!data || !data.members || !data.members.length){ showOnboard(); return; } me = localStorage.getItem(SESSION); if(!me || !member(me)) me = data.members[0].id; enterApp(); } /* ---------- онбординг ---------- */ function showOnboard(){ $('#onboard').classList.remove('hidden'); $('#app').classList.add('hidden'); const ob = $('#ob-members'); ob.innerHTML=''; addObMember(); addObMember(); } function addObMember(){ const idx = $('#ob-members').children.length; const color = AVATAR_COLORS[idx % AVATAR_COLORS.length]; const emoji = AVATAR_EMOJIS[idx % AVATAR_EMOJIS.length]; const row = document.createElement('div'); row.className='flex items-center gap-2'; row.innerHTML = ` ${emoji} `; row.querySelector('.ob-del').onclick=()=>{ if($('#ob-members').children.length>1) row.remove(); }; $('#ob-members').appendChild(row); } $('#ob-add-member').onclick = addObMember; $('#ob-create').onclick = ()=>{ const familyName = $('#ob-family').value.trim() || 'Наша семья'; const names = $$('#ob-members .ob-name').map(i=>i.value.trim()).filter(Boolean); if(names.length<1){ toast('Добавьте хотя бы одного члена семьи'); return; } const members = names.map((n,i)=>({ id:uid(), name:n, color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] })); data = { familyName, members, tasks:[] }; me = members[0].id; localStorage.setItem(SESSION, me); save(); $('#onboard').classList.add('hidden'); enterApp(); toast('Семья создана! 🎉'); }; /* ============================================================ ВХОД В ПРИЛОЖЕНИЕ ============================================================ */ function enterApp(){ $('#onboard').classList.add('hidden'); $('#app').classList.remove('hidden'); $('#hd-family').textContent = data.familyName; renderUserMenu(); setCurrentUser(me); setView('mine'); } /* ---------- переключение пользователя («вход») ---------- */ function renderUserMenu(){ const list = $('#hd-user-list'); list.innerHTML = data.members.map(m=>` `).join(''); list.querySelectorAll('.menu-row').forEach(r=>r.onclick=()=>{ setCurrentUser(r.dataset.uid); $('#hd-menu').classList.add('hidden'); setView(view); }); } function setCurrentUser(id){ me = id; localStorage.setItem(SESSION, id); const m = member(id); $('#hd-avatar').outerHTML = `${m.emoji||initials(m.name)}`; $('#hd-avatar').style.background = m.color; $('#hd-name').textContent = m.name; $('#f-from').textContent = m.name; } /* выпадающее меню пользователя */ $('#hd-user').onclick = e=>{ e.stopPropagation(); $('#hd-menu').classList.toggle('hidden'); }; document.addEventListener('click', e=>{ if(!e.target.closest('#hd-user') && !e.target.closest('#hd-menu')) $('#hd-menu').classList.add('hidden'); }); $('#hd-manage').onclick = ()=>{ $('#hd-menu').classList.add('hidden'); openFamilyModal(); }; /* ---------- навигация ---------- */ $$('.navtab').forEach(b=>b.onclick=()=>setView(b.dataset.view)); function setView(v){ view = v; $$('.navtab').forEach(b=>b.classList.toggle('active', b.dataset.view===v)); render(); } /* ---------- кнопка новая задача ---------- */ $('#hd-new').onclick = $('#hd-new-m').onclick = ()=>openTaskModal(null); /* ============================================================ ГЛАВНЫЙ РЕНДЕР ============================================================ */ function render(){ $('#hd-family').textContent = data.familyName; if(view==='mine') return renderMine(); if(view==='assigned') return renderAssigned(); if(view==='all') return renderAll(); if(view==='schedule') return renderSchedule(); if(view==='family') return renderFamily(); } function taskRow(t){ const a = member(t.assigneeId); const from = member(t.assignerId); const overdue = t.status==='todo' && t.dueDate && parseDate(t.dueDate) < new Date(todayStr()); const st = STATUS[t.status] || STATUS.todo; return `
${escapeHtml(t.title)}
${st.label}
${t.note?`
${escapeHtml(t.note)}
`:''}
${avatarHtml(a,16)} ${escapeHtml(a?a.name:'—')} ${from?`от ${escapeHtml(from.name)}`:''} ${t.dueDate?`⏰ ${prettyDateTime(t.dueDate,t.time)} · ${relativeDue(t.dueDate)}`:''} ${PRIORITY[t.priority].label}
${t.status!=='done'?``:``} ${t.status!=='cancelled' && t.status!=='done'?``:''} ${t.status!=='cancelled' && t.status!=='done'?``:''}
`; } function bindTaskRow(el){ const id = el.dataset.id; const t = data.tasks.find(x=>x.id===id); if(!t) return; const on = (sel, fn)=>el.querySelectorAll(sel).forEach(b=>{ if(!b.disabled) b.addEventListener('click', fn); }); on('.t-done', ()=>setStatus(id,'done')); on('.t-reopen', ()=>setStatus(id,'todo')); on('.t-postpone', ()=>openPostpone(id)); on('.t-cancel', ()=>setStatus(id,'cancelled')); on('.t-edit', ()=>openTaskModal(id)); } /* ---------- Мои задачи ---------- */ function renderMine(){ const me2 = member(me); const tasks = data.tasks.filter(t=>t.assigneeId===me && (t.status==='todo'||t.status==='postponed')) .sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999')); const doneCount = data.tasks.filter(t=>t.assigneeId===me && t.status==='done').length; const main = $('#main'); main.innerHTML = `

Мои задачи

${avatarHtml(me2,20)} ${escapeHtml(me2.name)} · выполнено за всё время: ${doneCount}

`; const list = $('#list'); if(!tasks.length){ list.innerHTML = emptyHtml('🎉','Активных задач нет. Можно отдохнуть!'); return; } list.innerHTML = tasks.map(taskRow).join(''); list.querySelectorAll('.task').forEach(bindTaskRow); } /* ---------- Я поручил ---------- */ function renderAssigned(){ const tasks = data.tasks.filter(t=>t.assignerId===me) .sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999')); const active = tasks.filter(t=>t.status==='todo'||t.status==='postponed').length; $('#main').innerHTML = `

Я поручил

Задачи, которые вы поставили другим. Активных: ${active}

`; const list = $('#list'); if(!tasks.length){ list.innerHTML = emptyHtml('📤','Вы ещё никому не поручали задач.','+ Новая задача','hd-new'); return; } list.innerHTML = tasks.map(taskRow).join(''); list.querySelectorAll('.task').forEach(bindTaskRow); } /* ---------- Все задачи ---------- */ function renderAll(){ const statuses = ['todo','postponed','done','cancelled']; $('#main').innerHTML = `

Все задачи семьи

Всего: ${data.tasks.length}

`; const apply = ()=>{ const fm = $('#flt-member').value, fs = $('#flt-status').value; let tasks = data.tasks.slice().sort((a,b)=>(b.createdAt||0)-(a.createdAt||0)); if(fm) tasks = tasks.filter(t=>t.assigneeId===fm||t.assignerId===fm); if(fs) tasks = tasks.filter(t=>t.status===fs); const list = $('#list'); if(!tasks.length){ list.innerHTML = emptyHtml('🗂','Задач не найдено по фильтру.'); return; } list.innerHTML = tasks.map(taskRow).join(''); list.querySelectorAll('.task').forEach(bindTaskRow); }; $('#flt-member').onchange = apply; $('#flt-status').onchange = apply; apply(); } /* ---------- Расписание (календарь) ---------- */ let calRef = new Date(); function renderSchedule(){ const y = calRef.getFullYear(), m = calRef.getMonth(); const first = new Date(y,m,1); const startDow = (first.getDay()+6)%7; // понедельник = 0 const daysInMonth = new Date(y,m+1,0).getDate(); const monthName = first.toLocaleDateString('ru-RU',{month:'long', year:'numeric'}); const tasksByDate = {}; data.tasks.filter(t=>t.dueDate && t.status!=='cancelled').forEach(t=>{ (tasksByDate[t.dueDate] = tasksByDate[t.dueDate]||[]).push(t); }); let cells = ''; const dows = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; cells += dows.map(d=>`
${d}
`).join(''); for(let i=0;i`; const today = todayStr(); for(let d=1; d<=daysInMonth; d++){ const ds = fmt(new Date(y,m,d)); const tlist = tasksByDate[ds]||[]; cells += `
${d}
${tlist.slice(0,3).map(t=>`
${escapeHtml(t.title)}
`).join('')} ${tlist.length>3?`
+${tlist.length-3} ещё
`:''}
`; } $('#main').innerHTML = `

${monthName}

${cells}
${data.members.map(m=>`${avatarHtml(m,14)} ${escapeHtml(m.name)}`).join('')}
`; $('#cal-prev').onclick=()=>{ calRef=new Date(y,m-1,1); renderSchedule(); }; $('#cal-next').onclick=()=>{ calRef=new Date(y,m+1,1); renderSchedule(); }; $('#cal-today').onclick=()=>{ calRef=new Date(); renderSchedule(); }; $$('#main .cal-task').forEach(el=>el.onclick=()=>openTaskModal(el.dataset.id)); } /* ---------- Семья ---------- */ function renderFamily(){ const cards = data.members.map(m=>{ const assigned = data.tasks.filter(t=>t.assigneeId===m.id && (t.status==='todo'||t.status==='postponed')).length; const done = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='done').length; const gave = data.tasks.filter(t=>t.assignerId===m.id).length; const overdue = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='todo' && t.dueDate && parseDate(t.dueDate) ${avatarHtml(m,52)}
${escapeHtml(m.name)}
поручил другим: ${gave}
${assigned}
активных
${done}
готово
${overdue}
просроч.
`; }).join(''); $('#main').innerHTML = `

${escapeHtml(data.familyName)}

Членов: ${data.members.length} · задач всего: ${data.tasks.length}

${cards}
`; $('#fam-manage').onclick = openFamilyModal; } function emptyHtml(emoji, text, btnLabel, btnId){ return `
${emoji}

${text}

${btnLabel?``:''}
`; } /* ============================================================ ДЕЙСТВИЯ С ЗАДАЧАМИ ============================================================ */ function setStatus(id, status){ const t = data.tasks.find(x=>x.id===id); if(!t) return; const prev = t.status; t.status = status; t.updatedAt = Date.now(); if(status==='done') t.completedAt = Date.now(); pushHistory(t, status); save(); render(); toast(STATUS[status].label+': '+t.title); } function pushHistory(t, action, detail){ t.history = t.history||[]; const labels = {done:'✅ Выполнено', todo:'↩ Возвращено в работу', postponed:'📅 Перенесено', cancelled:'✕ Отменено', created:'➕ Создано', edited:'✎ Изменено', assigned:'🔁 Назначено'}; t.history.push({ at:Date.now(), byId:me, action, detail:detail||labels[action]||action }); } function openPostpone(id){ postponeId = id; const t = data.tasks.find(x=>x.id===id); $('#pp-date').value = t.dueDate || todayStr(); $('#postpone-modal').classList.remove('hidden'); } $('#pp-cancel').onclick = ()=>$('#postpone-modal').classList.add('hidden'); $('#pp-ok').onclick = ()=>{ const t = data.tasks.find(x=>x.id===postponeId); if(!t) return; const newDate = $('#pp-date').value; t.dueDate = newDate; t.status = 'postponed'; t.updatedAt = Date.now(); pushHistory(t,'postponed','на '+prettyDate(newDate)); save(); $('#postpone-modal').classList.add('hidden'); render(); toast('Перенесено на '+prettyDate(newDate)); }; /* ============================================================ МОДАЛКА ЗАДАЧИ (создание/редактирование) ============================================================ */ function openTaskModal(id){ editingId = id; const t = id ? data.tasks.find(x=>x.id===id) : null; $('#modal-title').textContent = t ? 'Редактировать задачу' : 'Новая задача'; $('#f-title').value = t?.title||''; $('#f-note').value = t?.note||''; $('#f-date').value = t?.dueDate||''; $('#f-time').value = t?.time||''; $('#f-priority').value = t?.priority||'medium'; $('#f-from').textContent = member(me).name; // список исполнителей $('#f-assignee').innerHTML = data.members.map(m=>``).join(''); // история if(t && t.history && t.history.length){ $('#f-history').classList.remove('hidden'); $('#f-history-list').innerHTML = t.history.slice().reverse().map(h=>{ const by = member(h.byId); return `
${new Date(h.at).toLocaleString('ru-RU',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} · ${by?escapeHtml(by.name):'?'} — ${escapeHtml(h.detail)}
`; }).join(''); } else { $('#f-history').classList.add('hidden'); } $('#modal-delete').classList.toggle('hidden', !t); $('#modal').classList.remove('hidden'); setTimeout(()=>$('#f-title').focus(),50); } $('#modal-close').onclick = ()=>$('#modal').classList.add('hidden'); $('#modal').addEventListener('click', e=>{ if(e.target.id==='modal') $('#modal').classList.add('hidden'); }); $('#modal-save').onclick = ()=>{ const title = $('#f-title').value.trim(); if(!title){ toast('Введите название задачи'); $('#f-title').focus(); return; } const assigneeId = $('#f-assignee').value; const payload = { title, note: $('#f-note').value.trim(), assigneeId, dueDate: $('#f-date').value || null, time: $('#f-time').value || null, priority: $('#f-priority').value, }; if(editingId){ const t = data.tasks.find(x=>x.id===editingId); const reassigned = t.assigneeId !== assigneeId; Object.assign(t, payload, {updatedAt:Date.now()}); if(reassigned) pushHistory(t,'assigned','→ '+member(assigneeId).name); pushHistory(t,'edited'); toast('Изменено'); } else { const t = Object.assign({ id:uid(), assignerId:me, status:'todo', createdAt:Date.now(), updatedAt:Date.now(), history:[] }, payload); pushHistory(t,'created'); data.tasks.push(t); toast('Задача создана ✅'); } save(); $('#modal').classList.add('hidden'); render(); }; $('#modal-delete').onclick = ()=>{ if(!editingId) return; if(!confirm('Удалить задачу безвозвратно?')) return; data.tasks = data.tasks.filter(t=>t.id!==editingId); save(); $('#modal').classList.add('hidden'); render(); toast('Задача удалена'); }; /* ============================================================ УПРАВЛЕНИЕ СЕМЬЁЙ ============================================================ */ function openFamilyModal(){ $('#fm-name').value = data.familyName; renderFmList(); $('#family-modal').classList.remove('hidden'); } function renderFmList(){ $('#fm-list').innerHTML = data.members.map((m,i)=>`
${m.emoji||initials(m.name)}
`).join(''); $('#fm-list').children.forEach(row=>{ const mid = row.dataset.mid; row.querySelector('.fm-pick').onclick = ()=>cycleAvatar(row, mid); row.querySelector('.fm-emoji').onclick = ()=>cycleAvatar(row, mid); row.querySelector('.fm-del').onclick = ()=>{ const m = member(mid); if(!confirm(`Удалить «${m.name}»? Его задачи останутся, но без исполнителя.`)) return; data.members = data.members.filter(x=>x.id!==mid); data.tasks.forEach(t=>{ if(t.assigneeId===mid) t.assigneeId=null; if(t.assignerId===mid) t.assignerId=null; }); if(me===mid) me = data.members[0]?.id; renderFmList(); }; }); } function cycleAvatar(row, mid){ const m = member(mid); if(!m) return; const ci = AVATAR_COLORS.indexOf(m.color); const ei = AVATAR_EMOJIS.indexOf(m.emoji); m.color = AVATAR_COLORS[(ci+1)%AVATAR_COLORS.length]; m.emoji = AVATAR_EMOJIS[(ei+1)%AVATAR_EMOJIS.length]; const av = row.querySelector('.fm-emoji'); av.style.background = m.color; av.textContent = m.emoji||initials(m.name); } $('#fm-add').onclick = ()=>{ const i = data.members.length; data.members.push({ id:uid(), name:'Новый член', color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] }); renderFmList(); }; $('#fm-close').onclick = ()=>$('#family-modal').classList.add('hidden'); $('#family-modal').addEventListener('click', e=>{ if(e.target.id==='family-modal') $('#family-modal').classList.add('hidden'); }); $('#fm-save').onclick = ()=>{ data.familyName = $('#fm-name').value.trim() || data.familyName; $$('#fm-list .fm-name').forEach((inp,i)=>{ if(data.members[i]) data.members[i].name = inp.value.trim() || data.members[i].name; }); if(!member(me)) me = data.members[0]?.id; save(); $('#family-modal').classList.add('hidden'); renderUserMenu(); setCurrentUser(me); render(); toast('Семья обновлена'); }; /* ============================================================ ЗАПУСК ============================================================ */ init();