/* ===== Семейный таск-менеджер (чистая статика, 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 = `
${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}
`;
}).join('');
$('#main').innerHTML = `
Членов: ${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();