feat: категории задач, длительность, личные события в расписании

This commit is contained in:
ashirova_gulnur 2026-06-26 06:35:44 +00:00
parent 1d2281a0ec
commit c9ef8a852b
3 changed files with 256 additions and 18 deletions

192
app.js
View File

@ -50,11 +50,38 @@ const PRIORITY = {
high: {label:'🔴 Высокая', cls:'bg-red-50 text-red-600'},
};
const CATEGORIES = [
{key:'home', label:'🏠 Дом', color:'#10b981'},
{key:'work', label:'💼 Работа', color:'#3b82f6'},
{key:'study', label:'📚 Учёба', color:'#8b5cf6'},
{key:'shop', label:'🛒 Покупки', color:'#f59e0b'},
{key:'kids', label:'👶 Дети', color:'#ec4899'},
{key:'money', label:'💰 Финансы', color:'#06b6d4'},
{key:'health', label:'❤️ Здоровье', color:'#ef4444'},
{key:'other', label:'🎉 Другое', color:'#6366f1'},
];
function catByKey(k){ return CATEGORIES.find(c=>c.key===k) || CATEGORIES[CATEGORIES.length-1]; }
function catOptions(sel){ return CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join(''); }
function formatDuration(min){
min = Number(min); if(!min) return '';
if(min<60) return min+' мин';
const h=Math.floor(min/60), m=min%60;
return m? `${h} ч ${m} мин` : `${h} ч`;
}
/* ============================================================
ИНИЦИАЛИЗАЦИЯ
============================================================ */
function init(){
data = load();
if(data && data.members && data.members.length){
// миграции
data.events = data.events || [];
(data.tasks||[]).forEach(t=>{
if(!t.category) t.category = 'other';
if(t.duration===undefined) t.duration = null;
});
}
if(!data || !data.members || !data.members.length){ showOnboard(); return; }
me = localStorage.getItem(SESSION);
if(!me || !member(me)) me = data.members[0].id;
@ -89,7 +116,7 @@ $('#ob-create').onclick = ()=>{
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:[] };
data = { familyName, members, tasks:[], events:[] };
me = members[0].id;
localStorage.setItem(SESSION, me);
save();
@ -168,12 +195,15 @@ function taskRow(t){
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;
const cat = catByKey(t.category);
const dur = formatDuration(t.duration);
return `
<div class="task ${t.status==='done'?'done':''} ${t.status==='cancelled'?'cancelled':''} ${overdue?'overdue':''}" data-id="${t.id}">
<div class="flex items-start gap-3">
<button class="iconbtn primary t-done" title="Отметить выполненной" ${t.status==='done'?'disabled':''}></button>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 flex-wrap">
<span class="chip" style="background:${cat.color}22;color:${cat.color}">${cat.label}</span>
<div class="t-title font-semibold flex-1 min-w-0">${escapeHtml(t.title)}</div>
<span class="badge ${st.cls}">${st.label}</span>
</div>
@ -182,6 +212,7 @@ function taskRow(t){
<span class="chip" style="background:${(a&&a.color)||'#94a3b8'}22;color:${(a&&a.color)||'#94a3b8'}">${avatarHtml(a,16)} ${escapeHtml(a?a.name:'—')}</span>
${from?`<span class="text-muted">от ${escapeHtml(from.name)}</span>`:''}
${t.dueDate?`<span class="badge ${overdue?'bg-red-100 text-red-700':'bg-line text-muted'}">⏰ ${prettyDateTime(t.dueDate,t.time)} · ${relativeDue(t.dueDate)}</span>`:''}
${dur?`<span class="badge bg-gray-100 text-gray-600">⏱ ${dur}</span>`:''}
<span class="badge ${PRIORITY[t.priority].cls}">${PRIORITY[t.priority].label}</span>
</div>
</div>
@ -251,34 +282,49 @@ function renderAll(){
<div class="flex flex-wrap gap-2 mb-4" id="filters">
<select id="flt-member" class="field !w-auto !py-2"><option value="">Все члены</option>${data.members.map(m=>`<option value="${m.id}">${escapeHtml(m.name)}</option>`).join('')}</select>
<select id="flt-status" class="field !w-auto !py-2"><option value="">Любой статус</option>${statuses.map(s=>`<option value="${s}">${STATUS[s].label}</option>`).join('')}</select>
<select id="flt-cat" class="field !w-auto !py-2"><option value="">Все виды</option>${CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join('')}</select>
</div>
<div id="list" class="space-y-3"></div>`;
const apply = ()=>{
const fm = $('#flt-member').value, fs = $('#flt-status').value;
const fm = $('#flt-member').value, fs = $('#flt-status').value, fc = $('#flt-cat').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);
if(fc) tasks = tasks.filter(t=>t.category===fc);
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;
$('#flt-member').onchange = apply; $('#flt-status').onchange = apply; $('#flt-cat').onchange = apply;
apply();
}
/* ---------- Расписание (календарь) ---------- */
/* ---------- Расписание (календарь + личные события) ---------- */
let calRef = new Date();
let selDay = todayStr();
let schedScope = 'mine'; // 'mine' | 'all'
function dayItems(ds, scope){
let evs = (data.events||[]).filter(e=>e.date===ds);
let tks = data.tasks.filter(t=>t.dueDate===ds && t.status!=='cancelled');
if(scope==='mine'){
evs = evs.filter(e=>e.ownerId===me);
tks = tks.filter(t=>t.assigneeId===me);
}
return [
...evs.map(e=>({type:'event', sort:e.start||'00:00', obj:e})),
...tks.map(t=>({type:'task', sort:t.time||'zz', obj:t})),
].sort((a,b)=>a.sort.localeCompare(b.sort));
}
function renderSchedule(){
if(!selDay) selDay = todayStr();
const y = calRef.getFullYear(), m = calRef.getMonth();
const first = new Date(y,m,1);
const startDow = (first.getDay()+6)%7; // понедельник = 0
const startDow = (first.getDay()+6)%7;
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=>`<div class="cal-dow">${d}</div>`).join('');
@ -286,32 +332,137 @@ function renderSchedule(){
const today = todayStr();
for(let d=1; d<=daysInMonth; d++){
const ds = fmt(new Date(y,m,d));
const tlist = tasksByDate[ds]||[];
cells += `<div class="cal-day ${ds===today?'today':''}">
const items = dayItems(ds, schedScope);
cells += `<div class="cal-day ${ds===today?'today':''} ${ds===selDay?'selected':''}" data-day="${ds}">
<div class="cal-dnum">${d}</div>
${tlist.slice(0,3).map(t=>`<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-id="${t.id}">${escapeHtml(t.title)}</div>`).join('')}
${tlist.length>3?`<div class="text-xs text-muted">+${tlist.length-3} ещё</div>`:''}
${items.slice(0,3).map(it=>chipHtml(it)).join('')}
${items.length>3?`<div class="text-xs text-muted">+${items.length-3}</div>`:''}
</div>`;
}
$('#main').innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-4 gap-2 flex-wrap">
<h2 class="text-2xl font-extrabold capitalize">${monthName}</h2>
<div class="flex gap-2">
<div class="flex gap-2 items-center">
<div class="flex bg-line rounded-lg p-1">
<button id="sc-mine" class="seg ${schedScope==='mine'?'active':''}">Мои</button>
<button id="sc-all" class="seg ${schedScope==='all'?'active':''}">Вся семья</button>
</div>
<button id="cal-prev" class="iconbtn"></button>
<button id="cal-today" class="btn-ghost text-sm">Сегодня</button>
<button id="cal-next" class="iconbtn"></button>
<button id="cal-addev" class="btn-primary text-sm">+ Событие</button>
</div>
</div>
<div class="cal-grid">${cells}</div>
<div id="day-panel" class="mt-5"></div>
<div class="flex flex-wrap gap-3 mt-4 text-xs text-muted">
${data.members.map(m=>`<span class="flex items-center gap-1">${avatarHtml(m,14)} ${escapeHtml(m.name)}</span>`).join('')}
${CATEGORIES.map(c=>`<span class="flex items-center gap-1"><span class="dot" style="background:${c.color}"></span>${c.label}</span>`).join('')}
</div>`;
$('#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));
$('#cal-today').onclick=()=>{ calRef=new Date(); selDay=todayStr(); renderSchedule(); };
$('#cal-addev').onclick=()=>openEventModal(null, selDay);
$('#sc-mine').onclick=()=>{ schedScope='mine'; renderSchedule(); };
$('#sc-all').onclick=()=>{ schedScope='all'; renderSchedule(); };
$$('#main .cal-day[data-day]').forEach(el=>el.onclick=e=>{ if(e.target.closest('.cal-task')) return; selDay=el.dataset.day; renderSchedule(); });
$$('#main .cal-task').forEach(el=>el.onclick=e=>{ e.stopPropagation(); el.dataset.type==='event'?openEventModal(el.dataset.id):openTaskModal(el.dataset.id); });
renderDayPanel();
}
function chipHtml(it){
if(it.type==='event'){
const c=catByKey(it.obj.category);
return `<div class="cal-task" style="background:${c.color}22;color:${c.color}" data-type="event" data-id="${it.obj.id}">${escapeHtml(it.obj.start)} ${escapeHtml(it.obj.title)}</div>`;
}
const t=it.obj;
return `<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-type="task" data-id="${t.id}">✓ ${escapeHtml(t.title)}</div>`;
}
function renderDayPanel(){
const panel = $('#day-panel'); if(!panel) return;
const items = dayItems(selDay, schedScope);
const d = parseDate(selDay);
const dname = d ? d.toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'}) : selDay;
const isToday = selDay===todayStr();
panel.innerHTML = `
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="font-bold text-lg capitalize">${dname} ${isToday?'<span class="badge bg-accent-50 text-ink">сегодня</span>':''}</h3>
<button id="dp-addev" class="btn-ghost text-sm">+ событие в этот день</button>
</div>
${items.length? `<div class="space-y-2">${items.map(it=>dayRowHtml(it)).join('')}</div>` : emptyHtml('📅','На этот день ничего не запланировано.')}`;
$('#dp-addev')?.addEventListener('click', ()=>openEventModal(null, selDay));
panel.querySelectorAll('.day-row').forEach(r=>{
r.onclick=()=>{ r.dataset.type==='event'?openEventModal(r.dataset.id):openTaskModal(r.dataset.id); };
});
}
function dayRowHtml(it){
if(it.type==='event'){
const e=it.obj, c=catByKey(e.category), owner=member(e.ownerId);
return `<div class="day-row stat-card flex items-center gap-3" data-type="event" data-id="${e.id}" style="cursor:pointer">
<div class="time-badge" style="background:${c.color}1a;color:${c.color}">${escapeHtml(e.start)}<br><span class="opacity-60">${escapeHtml(e.end)}</span></div>
<div class="dot" style="background:${c.color}"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold">${escapeHtml(e.title)}</div>
<div class="text-xs text-muted">${avatarHtml(owner,14)} ${escapeHtml(owner?owner.name:'—')} · ${c.label}</div>
</div>
<span class="text-muted"></span>
</div>`;
}
const t=it.obj, cat=catByKey(t.category), a=member(t.assigneeId);
return `<div class="day-row stat-card flex items-center gap-3" data-type="task" data-id="${t.id}" style="cursor:pointer">
<div class="time-badge" style="background:${colorFor(t.assigneeId)}1a;color:${colorFor(t.assigneeId)}">${escapeHtml(t.time||'—')}</div>
<div class="dot" style="background:${colorFor(t.assigneeId)}"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold ${t.status==='done'?'done':''}">${escapeHtml(t.title)}</div>
<div class="text-xs text-muted">${avatarHtml(a,14)} ${escapeHtml(a?a.name:'—')} · ${cat.label}${t.duration?' · ⏱ '+formatDuration(t.duration):''}</div>
</div>
<span class="badge ${STATUS[t.status].cls}">${STATUS[t.status].label}</span>
<span class="text-muted"></span>
</div>`;
}
/* ---------- модалка события ---------- */
let editingEventId = null;
function openEventModal(id, dateStr){
editingEventId = id;
const e = id ? data.events.find(x=>x.id===id) : null;
$('#ev-title').textContent = e ? 'Событие' : 'Новое событие';
$('#ev-name').value = e?.title||'';
$('#ev-date').value = e?.date || dateStr || selDay || todayStr();
$('#ev-start').value = e?.start||'';
$('#ev-end').value = e?.end||'';
$('#ev-owner').innerHTML = data.members.map(m=>`<option value="${m.id}" ${(e?e.ownerId===m.id:m.id===me)?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
$('#ev-cat').innerHTML = catOptions();
$('#ev-cat').value = e?.category||'other';
$('#ev-delete').classList.toggle('hidden', !e);
$('#event-modal').classList.remove('hidden');
setTimeout(()=>$('#ev-name').focus(),50);
}
$('#ev-close').onclick = ()=>$('#event-modal').classList.add('hidden');
$('#event-modal').addEventListener('click', e=>{ if(e.target.id==='event-modal') $('#event-modal').classList.add('hidden'); });
$('#ev-save').onclick = ()=>{
const title=$('#ev-name').value.trim();
const date=$('#ev-date').value;
const start=$('#ev-start').value, end=$('#ev-end').value;
if(!title){ toast('Введите название'); $('#ev-name').focus(); return; }
if(!date){ toast('Выберите дату'); return; }
if(!start||!end){ toast('Укажите время начала и конца'); return; }
if(start>=end){ toast('Конец должен быть позже начала'); return; }
const payload={ title, date, start, end, ownerId:$('#ev-owner').value, category:$('#ev-cat').value };
if(editingEventId){ Object.assign(data.events.find(x=>x.id===editingEventId), payload); toast('Сохранено'); }
else { data.events.push(Object.assign({id:uid()}, payload)); toast('Событие добавлено 📅'); }
selDay = date;
save(); $('#event-modal').classList.add('hidden'); render();
};
$('#ev-delete').onclick = ()=>{
if(!editingEventId) return;
if(!confirm('Удалить событие?')) return;
data.events = data.events.filter(x=>x.id!==editingEventId);
save(); $('#event-modal').classList.add('hidden'); render();
toast('Событие удалено');
};
/* ---------- Семья ---------- */
function renderFamily(){
const cards = data.members.map(m=>{
@ -401,6 +552,9 @@ function openTaskModal(id){
$('#f-date').value = t?.dueDate||'';
$('#f-time').value = t?.time||'';
$('#f-priority').value = t?.priority||'medium';
$('#f-duration').value = t?.duration||'';
$('#f-category').innerHTML = catOptions();
$('#f-category').value = t?.category||'other';
$('#f-from').textContent = member(me).name;
// список исполнителей
$('#f-assignee').innerHTML = data.members.map(m=>`<option value="${m.id}" ${t&&t.assigneeId===m.id?'selected':m.id===me&&!t?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
@ -428,7 +582,9 @@ $('#modal-save').onclick = ()=>{
assigneeId,
dueDate: $('#f-date').value || null,
time: $('#f-time').value || null,
duration: $('#f-duration').value ? Number($('#f-duration').value) : null,
priority: $('#f-priority').value,
category: $('#f-category').value,
};
if(editingId){
const t = data.tasks.find(x=>x.id===editingId);

View File

@ -120,6 +120,27 @@ window.addEventListener('error',function(e){
<label class="block text-sm font-semibold mb-1">Время</label>
<input id="f-time" type="time" class="field">
</div>
<div>
<label class="block text-sm font-semibold mb-1">Длительность</label>
<select id="f-duration" class="field">
<option value="">— не указана —</option>
<option value="15">15 мин</option>
<option value="30">30 мин</option>
<option value="45">45 мин</option>
<option value="60">1 ч</option>
<option value="90">1.5 ч</option>
<option value="120">2 ч</option>
<option value="180">3 ч</option>
<option value="240">4 ч</option>
<option value="360">6 ч</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold mb-1">Вид</label>
<select id="f-category" class="field"></select>
</div>
<div>
<label class="block text-sm font-semibold mb-1">Важность</label>
<select id="f-priority" class="field">
@ -185,6 +206,53 @@ window.addEventListener('error',function(e){
</div>
</div>
<!-- Модалка личного события (расписание) -->
<div id="event-modal" class="hidden fixed inset-0 z-40 bg-black/50 flex items-end sm:items-center justify-center p-0 sm:p-4">
<div class="bg-white w-full sm:max-w-lg sm:rounded-2xl rounded-t-2xl shadow-2xl max-h-[92vh] overflow-auto">
<div class="sticky top-0 bg-white border-b border-line px-5 py-3 flex items-center justify-between">
<h3 id="ev-title" class="font-bold text-lg">Событие в расписании</h3>
<button id="ev-close" class="text-2xl leading-none text-muted hover:text-ink px-2">×</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-semibold mb-1">Что и где *</label>
<input id="ev-name" class="field" placeholder="Напр. Школа, Бассейн, Работа">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold mb-1">Дата *</label>
<input id="ev-date" type="date" class="field">
</div>
<div>
<label class="block text-sm font-semibold mb-1">Чьё расписание</label>
<select id="ev-owner" class="field"></select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold mb-1">С *</label>
<input id="ev-start" type="time" class="field">
</div>
<div>
<label class="block text-sm font-semibold mb-1">До *</label>
<input id="ev-end" type="time" class="field">
</div>
</div>
<div>
<label class="block text-sm font-semibold mb-1">Вид / цвет</label>
<select id="ev-cat" class="field"></select>
</div>
<div class="bg-line rounded-lg p-3 text-xs text-muted">
Личные события помогают планировать день: школа, работа, тренировки. Они не зависят от задач и видны в расписании.
</div>
</div>
<div class="sticky bottom-0 bg-white border-t border-line px-5 py-3 flex gap-2">
<button id="ev-delete" class="hidden btn-danger">Удалить</button>
<button id="ev-save" class="btn-primary flex-1">Сохранить</button>
</div>
</div>
</div>
<!-- Тост -->
<div id="toast" class="hidden fixed bottom-5 left-1/2 -translate-x-1/2 z-50 bg-ink text-white px-5 py-3 rounded-full shadow-2xl text-sm font-semibold"></div>

View File

@ -53,6 +53,8 @@ textarea.field{resize:vertical}
.cal-day{min-height:96px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:6px;overflow:hidden}
.cal-day.off{background:#fafbfc;color:#c2c8d2}
.cal-day.today{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-50) inset}
.cal-day.selected{outline:2px solid var(--accent);outline-offset:-2px}
.cal-day[data-day]{cursor:pointer}
.cal-dnum{font-size:12px;font-weight:700;margin-bottom:4px}
.cal-task{font-size:11px;font-weight:600;border-radius:5px;padding:2px 5px;margin-bottom:3px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@ -61,6 +63,18 @@ textarea.field{resize:vertical}
.stat-card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px}
/* сегмент-переключатель */
.seg{border:none;background:none;padding:6px 12px;font-size:13px;font-weight:600;color:var(--muted);border-radius:7px;cursor:pointer;font-family:inherit}
.seg.active{background:#fff;color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.1)}
/* элементы расписания */
.dot{width:10px;height:10px;border-radius:999px;flex-shrink:0}
.time-badge{font-size:11px;font-weight:700;text-align:center;line-height:1.2;min-width:52px;padding:6px 4px;border-radius:8px;white-space:nowrap}
.time-badge span{display:block;font-size:10px;font-weight:600}
.day-row{transition:box-shadow .15s}
.day-row:hover{box-shadow:0 4px 14px rgba(15,18,24,.08)}
.day-row .done{text-decoration:line-through;opacity:.6}
.menu-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;cursor:pointer;transition:.12s}
.menu-row:hover{background:var(--line)}
.menu-row.active{background:var(--accent-50)}