488 lines
20 KiB
JavaScript
488 lines
20 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
var STORAGE_KEY = "rezim-day-1";
|
||
var WEEKS = 4;
|
||
var WD = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
|
||
|
||
var DEFAULTS = {
|
||
wake: "07:00",
|
||
bedtime: "22:30",
|
||
schedule: [
|
||
{ time: "07:00", title: "Подъём и стакан воды" },
|
||
{ time: "07:10", title: "Зарядка 10–15 минут" },
|
||
{ time: "07:30", title: "Завтрак" },
|
||
{ time: "08:00", title: "Дорога и начало дела" },
|
||
{ time: "13:00", title: "Обед" },
|
||
{ time: "13:30", title: "Короткий отдых 15 минут" },
|
||
{ time: "14:00", title: "Работа / учёба" },
|
||
{ time: "18:00", title: "Конец рабочего дня" },
|
||
{ time: "19:00", title: "Ужин" },
|
||
{ time: "20:00", title: "Спорт / прогулка" },
|
||
{ time: "21:30", title: "Без экранов, вечер без спешки" },
|
||
{ time: "22:30", title: "Сон" }
|
||
],
|
||
habits: [
|
||
{ id: "h1", name: "Спорт", goal: 3, note: "Хотя бы 30 минут движения", done: [] },
|
||
{ id: "h2", name: "30 минут чтения", goal: 5, note: "Книга перед сном вместо ленты", done: [] },
|
||
{ id: "h3", name: "Дневник 3 строк", goal: 7, note: "Что получилось, что улучшить", done: [] },
|
||
{ id: "h4", name: "Готовить дома", goal: 5, note: "Завтрак или ужин", done: [] }
|
||
]
|
||
};
|
||
|
||
var state = null;
|
||
var serverLogReady = false;
|
||
|
||
// ---------- даты ----------
|
||
function iso(d) {
|
||
var p = function (n) { return String(n).padStart(2, "0"); };
|
||
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
|
||
}
|
||
function daysAgo(n) { var d = new Date(); d.setDate(d.getDate() - n); return d; }
|
||
function lastNDays() {
|
||
var a = [];
|
||
for (var i = WEEKS * 7 - 1; i >= 0; i--) a.push(iso(daysAgo(i)));
|
||
return a;
|
||
}
|
||
function fmtDate(d, withYear) {
|
||
var months = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"];
|
||
var s = d.getDate() + " " + months[d.getMonth()];
|
||
if (withYear) s += " " + d.getFullYear();
|
||
return s;
|
||
}
|
||
function todayStr() { return iso(new Date()); }
|
||
|
||
// ---------- данные ----------
|
||
function freshLog() {
|
||
var log = {};
|
||
lastNDays().forEach(function (d) { log[d] = { done: [], habits: {} }; });
|
||
return log;
|
||
}
|
||
function loadLocal() {
|
||
var data = null;
|
||
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (_) {}
|
||
return data;
|
||
}
|
||
function saveLocal() {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (_) {}
|
||
}
|
||
function todayEntry() {
|
||
var t = todayStr();
|
||
if (!state.log[t]) { state.log[t] = { done: [], habits: {} }; }
|
||
return state.log[t];
|
||
}
|
||
|
||
function applyLocal() {
|
||
var local = loadLocal();
|
||
if (local && local.log && Object.keys(local.log).length) {
|
||
state.log = Object.assign(freshLog(), local.log);
|
||
} else {
|
||
state.log = seedLog();
|
||
}
|
||
saveLocal();
|
||
}
|
||
function seedLog() {
|
||
var log = freshLog();
|
||
lastNDays().forEach(function (d, i) {
|
||
var e = log[d];
|
||
if (d === todayStr()) return;
|
||
if (i % 7 === 5) { e.done = []; e.sleep = 8.5; return; }
|
||
e.done = state.schedule.slice(0, i % 6 === 0 ? state.schedule.length - 2 : state.schedule.length - 1).map(function (it) { return it.time; });
|
||
e.sleep = Math.round((7 + (i % 3) * 0.5) * 10) / 10;
|
||
});
|
||
return log;
|
||
}
|
||
|
||
function seedDemoDays() {
|
||
if (loadLocal()) return;
|
||
state.log = seedLog();
|
||
saveLocal();
|
||
}
|
||
|
||
// ---------- сервер ----------
|
||
function pushToServer() {
|
||
var payload = {
|
||
wake: state.wake,
|
||
bedtime: state.bedtime,
|
||
schedule: state.schedule,
|
||
habits: state.habits,
|
||
log: state.log
|
||
};
|
||
fetch("api/state", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
}).then(pushOk).catch(function () { pushFail(); });
|
||
}
|
||
var pushTimer = null;
|
||
function pushOk() {
|
||
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
||
if ("serviceWorker" in navigator && !serverLogReady) {
|
||
pushTimer = setTimeout(function () {
|
||
pushTimer = null;
|
||
if ("serviceWorker" in navigator) pushFail();
|
||
}, 600);
|
||
} else {
|
||
serverLogReady = true;
|
||
}
|
||
}
|
||
function pushFail() {
|
||
toast("Не удалось сохранить на сервере — изменения пока только на этом устройстве", "warn");
|
||
}
|
||
|
||
// ---------- утилиты ----------
|
||
function toast(msg, tone) {
|
||
var stack = document.getElementById("toasts");
|
||
var el = document.createElement("div");
|
||
el.className = "kt-ai-toast";
|
||
if (tone) el.setAttribute("data-tone", tone);
|
||
el.textContent = msg;
|
||
stack.appendChild(el);
|
||
setTimeout(function () { el.remove(); }, 4500);
|
||
}
|
||
function sleepHours() {
|
||
function parts(v) { var ab = String(v || "").split(":"); return [parseInt(ab[0], 10) || 0, parseInt(ab[1], 10) || 0]; }
|
||
var b = parts(state.bedtime), w = parts(state.wake);
|
||
var bm = b[0] * 60 + b[1], wm = w[0] * 60 + w[1];
|
||
if (bm >= wm) return 24 - (bm - wm) / 60;
|
||
return (1440 - bm + wm) / 60;
|
||
}
|
||
|
||
// ---------- KPI и статусы ----------
|
||
function currentDayProgress(e) {
|
||
var total = state.schedule.length;
|
||
var done = (e && e.done ? e.done : []).filter(function (tm) {
|
||
return state.schedule.some(function (it) { return it.time === tm; });
|
||
}).length;
|
||
return { total: total, done: done, pct: total ? Math.round((done / total) * 100) : 0 };
|
||
}
|
||
function weekAvgProgress() {
|
||
var days = lastNDays().slice(-7);
|
||
var sum = 0;
|
||
days.forEach(function (d) {
|
||
var e = state.log[d];
|
||
if (e) sum += currentDayProgress(e).pct;
|
||
});
|
||
return Math.round(sum / 7);
|
||
}
|
||
function currentDayStreak() {
|
||
var days = lastNDays();
|
||
var streak = 0;
|
||
for (var i = days.length - 1; i >= 0; i--) {
|
||
var e = state.log[days[i]];
|
||
if (e && e.done.length >= state.schedule.length && e.done.length > 0) streak++;
|
||
else if (days[i] !== todayStr()) break;
|
||
}
|
||
return streak === 0 && (function () { var e = state.log[todayStr()]; return e && e.done.length > 0; })() ? 1 : streak;
|
||
}
|
||
|
||
// ---------- рендер ----------
|
||
function renderTopbar() {
|
||
var now = new Date();
|
||
document.getElementById("dateChip").textContent = WD[now.getDay()] + " · " + fmtDate(now);
|
||
}
|
||
|
||
function renderKpi() {
|
||
var t = todayEntry();
|
||
var cur = currentDayProgress(t);
|
||
document.getElementById("kpiDone").textContent = cur.done + " из " + cur.total;
|
||
var weekAvg = weekAvgProgress();
|
||
document.getElementById("kpiWeek").textContent = weekAvg + " %";
|
||
document.querySelector("#kWeek .pfill").style.width = weekAvg + "%";
|
||
document.getElementById("kpiStreak").textContent = currentDayStreak();
|
||
var st = sleepHours().toFixed(1).replace('.', ',');
|
||
document.getElementById("kpiSleep").textContent = st + " ч";
|
||
}
|
||
|
||
function renderDayList() {
|
||
var t = todayEntry();
|
||
var rows = state.schedule.map(function (it, idx) {
|
||
var ck = t.done.indexOf(it.time) !== -1;
|
||
return '<div class="kt-ai-row item-row" data-idx="' + idx + '">' +
|
||
'<label class="kt-ai-checkbox item-check"><input type="checkbox" ' + (ck ? "checked" : "") + ' aria-label="' + it.title + '"></label>' +
|
||
'<span><span class="time">' + it.time + '</span> <span class="item-name">' + it.title + '</span></span>' +
|
||
'<span class="end">' + (ck ? '<span class="kt-ai-status-pill" data-status="ok">готово</span>' : '') + '</span>' +
|
||
'</div>';
|
||
}).join("");
|
||
var total = state.schedule.length;
|
||
var done = t.done.filter(function (tm) { return state.schedule.some(function (it) { return it.time === tm; }); }).length;
|
||
document.getElementById("dayList").innerHTML =
|
||
'<div class="kt-ai-table-wrap">' +
|
||
'<table class="kt-ai-table">' +
|
||
'<thead><tr><th style="width:36px"></th><th>Час</th><th>Пункт</th><th></th></tr></thead>' +
|
||
'<tbody>' + rows + '</tbody>' +
|
||
'</table>' +
|
||
'</div>';
|
||
document.getElementById("progressBar").style.width = (total ? (done / total) * 100 : 0) + "%";
|
||
document.getElementById("progressText").textContent = done + " из " + total;
|
||
}
|
||
|
||
function renderWeekGrid() {
|
||
var days = lastNDays().slice(-7);
|
||
var head = days.map(function (d) {
|
||
var dt = new Date(d + "T12:00:00");
|
||
var isToday = d === todayStr();
|
||
var p = currentDayProgress(state.log[d]);
|
||
var on = d === todayStr() || p.pct > 0;
|
||
return '<span data-dkeyday="' + d + '" style="justify-self:center;display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer">' +
|
||
'<span style="font-size:var(--kt-ai-text-2xs);color:' + (isToday ? "var(--kt-ai-fg)" : "var(--kt-ai-fg-muted)") + ';font-weight:' + (isToday ? "600" : "400") + '">' + WD[dt.getDay()] + "</span>" +
|
||
'<i style="height:14px;width:14px;border-radius:50%;background:' + (on ? "var(--kt-ai-primary)" : "var(--kt-ai-bg-hover)") + (isToday ? ";outline:2px solid var(--kt-ai-primary);outline-offset:2px" : "") + '"></i>' +
|
||
"</span>";
|
||
}).join("");
|
||
var grid = '<div class="wcell" style="background:none;border:none;padding:0" data-head="1">' + head + "</div>";
|
||
state.habits.forEach(function (h) {
|
||
var cells = days.map(function (d) {
|
||
var on = h.done.indexOf(d) !== -1;
|
||
var isToday = d === todayStr();
|
||
return "<i data-habid='" + h.id + "' data-day='" + d + "'" + (on ? " class='on'" : "") + (isToday ? " data-today='1'" : "") + " title='" + d + "'></i>";
|
||
}).join("");
|
||
var weekDone = days.filter(function (d) { return h.done.indexOf(d) !== -1; }).length;
|
||
grid += '<div class="wcell" data-habid="' + h.id + '" role="button" tabindex="0" style="cursor:pointer">' +
|
||
"<span>" + h.name + "</span>" +
|
||
'<div class="wcells">' + cells + "</div>" +
|
||
"<b class='" + (weekDone >= h.goal ? "full" : "") + "'>" + weekDone + " / " + h.goal + "</b>" +
|
||
"</div>";
|
||
});
|
||
document.getElementById("weekGrid").innerHTML = grid;
|
||
}
|
||
|
||
function renderSleep() {
|
||
var b = document.querySelector('[data-sleepkey="bedtime"]');
|
||
var w = document.querySelector('[data-sleepkey="wake"]');
|
||
if (b) b.innerHTML = '<svg class="kt-icon" aria-hidden="true"><use href="#moon"></use></svg><span>' + state.bedtime + "</span>";
|
||
if (w) w.innerHTML = '<svg class="kt-icon" aria-hidden="true"><use href="#sun"></use></svg><span>' + state.wake + "</span>";
|
||
}
|
||
|
||
function renderAll() {
|
||
renderTopbar();
|
||
renderKpi();
|
||
renderDayList();
|
||
renderWeekGrid();
|
||
renderSleep();
|
||
}
|
||
|
||
// ---------- действия ----------
|
||
function afterChange() { saveLocal(); pushToServer(); renderAll(); }
|
||
|
||
function toggleItem(time) {
|
||
var t = todayEntry();
|
||
var i = t.done.indexOf(time);
|
||
if (i === -1) t.done.push(time);
|
||
else t.done.splice(i, 1);
|
||
if (t.done.length === state.schedule.length && typeof t.sleep !== "number") t.sleep = sleepHours();
|
||
afterChange();
|
||
}
|
||
function toggleHabitDay(habId, day) {
|
||
var h = state.habits.find(function (x) { return x.id === habId; });
|
||
if (!h) return;
|
||
var i = h.done.indexOf(day);
|
||
if (i === -1) h.done.push(day);
|
||
else h.done.splice(i, 1);
|
||
afterChange();
|
||
}
|
||
function markAllToday() {
|
||
var t = todayEntry();
|
||
var allDone = t.done.length >= state.schedule.length;
|
||
state.schedule.forEach(function (it) {
|
||
var i = t.done.indexOf(it.time);
|
||
if (allDone && i !== -1) t.done.splice(i, 1);
|
||
if (!allDone && i === -1) t.done.push(it.time);
|
||
});
|
||
if (typeof t.sleep !== "number") t.sleep = sleepHours();
|
||
toast(allDone ? "День сброшен" : "Все пункты отмечены — отличный день", "ok");
|
||
afterChange();
|
||
}
|
||
|
||
// ---------- drawer: день ----------
|
||
var drawerDay = null;
|
||
function openDayDrawer(day) {
|
||
drawerDay = day;
|
||
var dt = new Date(day + "T12:00:00");
|
||
var e = state.log[day] || { done: [], habits: {} };
|
||
var cur = currentDayProgress(e);
|
||
var isToday = day === todayStr();
|
||
var pill = document.getElementById("dayDrawerPill");
|
||
if (isToday) {
|
||
pill.setAttribute("data-status", cur.done === 0 ? "info" : "warn");
|
||
pill.textContent = cur.done + " из " + cur.total + " — твой текущий день";
|
||
} else if (cur.pct === 100) {
|
||
pill.setAttribute("data-status", "ok"); pill.textContent = "День по плану — 100%";
|
||
} else if (cur.done === 0) {
|
||
pill.setAttribute("data-status", "risk"); pill.textContent = "День не выполнен";
|
||
} else {
|
||
pill.setAttribute("data-status", "warn"); pill.textContent = "Выполнено на " + cur.pct + " %";
|
||
}
|
||
document.getElementById("dayDrawerDate").textContent = WD[dt.getDay()] + ", " + fmtDate(dt, true);
|
||
var rows = state.schedule.map(function (it) {
|
||
var ck = (e.done || []).indexOf(it.time) !== -1;
|
||
return '<div class="kt-ai-row">' +
|
||
'<span class="time">' + it.time + "</span><span>" + it.title + "</span>" +
|
||
'<span class="end"><span class="kt-ai-status-pill" data-status="' + (ck ? "ok" : "info") + '">' + (ck ? "готово" : "не сделано") + "</span></span></div>";
|
||
}).join("");
|
||
if (typeof e.sleep === "number") {
|
||
rows += '<div class="kt-ai-row"><span class="time"></span><span>Сон за этот день</span>' +
|
||
'<span class="end"><span class="kt-ai-status-pill" data-status="' + (e.sleep >= 7 ? "ok" : "warn") + '">' + e.sleep.toFixed(1).replace(".", ",") + " ч</span></span></div>";
|
||
}
|
||
document.getElementById("dayDrawerList").innerHTML = rows || '<div class="kt-ai-empty"><div class="title">Нет данных</div></div>';
|
||
document.getElementById("dayDrawer").style.display = "block";
|
||
}
|
||
|
||
// ---------- drawer: привычка ----------
|
||
var drawerHabitId = null;
|
||
function openHabitDrawer(habId) {
|
||
var h = state.habits.find(function (x) { return x.id === habId; });
|
||
if (!h) return;
|
||
drawerHabitId = habId;
|
||
document.getElementById("habitDrawerTitle").textContent = h.name;
|
||
document.getElementById("habitDrawerNote").textContent = h.note || "";
|
||
renderHabitDrawerCells();
|
||
document.getElementById("habitDrawer").style.display = "block";
|
||
}
|
||
function renderHabitDrawerCells() {
|
||
var h = state.habits.find(function (x) { return x.id === drawerHabitId; });
|
||
if (!h) return;
|
||
var days = lastNDays().slice(-7);
|
||
var wrap = document.getElementById("habitDrawerCells");
|
||
wrap.innerHTML = days.map(function (d) {
|
||
var dt = new Date(d + "T12:00:00");
|
||
var on = h.done.indexOf(d) !== -1;
|
||
return "<i data-habid2='" + h.id + "' data-day='" + d + "'" + (on ? " class='on'" : "") + (d === todayStr() ? " data-today='1'" : "") + " title='" + WD[dt.getDay()] + " · " + d + "'></i>";
|
||
}).join("");
|
||
}
|
||
function deleteHabit() {
|
||
if (!drawerHabitId) return;
|
||
state.habits = state.habits.filter(function (x) { return x.id !== drawerHabitId; });
|
||
drawerHabitId = null;
|
||
document.getElementById("habitDrawer").style.display = "none";
|
||
afterChange();
|
||
toast("Привычка удалена");
|
||
}
|
||
|
||
// ---------- modal: новая привычка ----------
|
||
function openHabitModal() {
|
||
document.getElementById("hName").value = "";
|
||
document.getElementById("hNote").value = "";
|
||
document.getElementById("hDays").value = "5";
|
||
document.getElementById("habitOverlay").style.display = "flex";
|
||
document.getElementById("habitModal").hidden = false;
|
||
setTimeout(function () { document.getElementById("hName").focus(); }, 50);
|
||
}
|
||
function closeHabitModal() {
|
||
document.getElementById("habitOverlay").style.display = "none";
|
||
document.getElementById("habitModal").hidden = true;
|
||
}
|
||
function saveHabit() {
|
||
var name = document.getElementById("hName").value.trim();
|
||
if (!name) { toast("Дай привычке название", "warn"); return; }
|
||
state.habits.push({
|
||
id: "h" + Date.now().toString(36),
|
||
name: name,
|
||
note: document.getElementById("hNote").value.trim(),
|
||
goal: parseInt(document.getElementById("hDays").value, 10) || 5,
|
||
done: []
|
||
});
|
||
closeHabitModal();
|
||
afterChange();
|
||
toast("Привычка добавлена", "ok");
|
||
}
|
||
|
||
function closeDrawers() {
|
||
document.getElementById("dayDrawer").style.display = "none";
|
||
document.getElementById("habitDrawer").style.display = "none";
|
||
drawerDay = null; drawerHabitId = null;
|
||
}
|
||
|
||
// ---------- события ----------
|
||
function bind() {
|
||
var dayList = document.getElementById("dayList");
|
||
dayList.addEventListener("change", function (ev) {
|
||
var cb = ev.target.closest("input[type=checkbox]");
|
||
if (!cb) return;
|
||
var row = cb.closest("[data-idx]");
|
||
var it = state.schedule[parseInt(row.getAttribute("data-idx"), 10)];
|
||
if (it) toggleItem(it.time);
|
||
});
|
||
dayList.addEventListener("click", function (ev) {
|
||
if (ev.target.closest(".kt-ai-checkbox")) return;
|
||
var name = ev.target.closest(".item-name");
|
||
if (!name) return;
|
||
var row = name.closest("[data-idx]");
|
||
var it = state.schedule[parseInt(row.getAttribute("data-idx"), 10)];
|
||
if (it) toast(it.time + " · " + it.title + " — отметь галочкой, когда сделаешь", "info");
|
||
});
|
||
|
||
var grid = document.getElementById("weekGrid");
|
||
grid.addEventListener("click", function (ev) {
|
||
var dcell = ev.target.closest("[data-dkeyday]");
|
||
if (dcell) { openDayDrawer(dcell.getAttribute("data-dkeyday")); return; }
|
||
var hcell = ev.target.closest(".wcell[data-habid]");
|
||
if (hcell) openHabitDrawer(hcell.getAttribute("data-habid"));
|
||
});
|
||
grid.addEventListener("keydown", function (ev) {
|
||
if (ev.key !== "Enter" && ev.key !== " ") return;
|
||
var cell = ev.target.closest(".wcell[data-habid]");
|
||
if (!cell) return;
|
||
ev.preventDefault();
|
||
openHabitDrawer(cell.getAttribute("data-habid"));
|
||
});
|
||
|
||
document.getElementById("habitDrawer").addEventListener("click", function (ev) {
|
||
var c = ev.target.closest("i[data-habid2]");
|
||
if (c) toggleHabitDay(c.getAttribute("data-habid2"), c.getAttribute("data-day"));
|
||
});
|
||
|
||
document.getElementById("btnToday").addEventListener("click", markAllToday);
|
||
document.getElementById("btnAddHabit").addEventListener("click", openHabitModal);
|
||
document.getElementById("btnHabitCancel").addEventListener("click", closeHabitModal);
|
||
document.getElementById("btnHabitSave").addEventListener("click", saveHabit);
|
||
document.getElementById("btnDeleteHabit").addEventListener("click", deleteHabit);
|
||
document.getElementById("habitOverlay").addEventListener("click", closeHabitModal);
|
||
|
||
document.addEventListener("keydown", function (ev) {
|
||
if (ev.key !== "Escape") return;
|
||
closeDrawers(); closeHabitModal();
|
||
});
|
||
|
||
["#day", "#habits", "#sleep"].forEach(function (sel) {
|
||
var a = document.querySelector('.kt-ai-nav-item[href="' + sel + '"]');
|
||
if (!a) return;
|
||
a.addEventListener("click", function (ev) {
|
||
ev.preventDefault();
|
||
document.querySelector(sel).scrollIntoView({ behavior: "smooth", block: "start" });
|
||
a.classList.add("is-active");
|
||
document.querySelectorAll(".kt-ai-nav-item").forEach(function (x) { if (x !== a) x.classList.remove("is-active"); });
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- старт ----------
|
||
(function init() {
|
||
state = {
|
||
wake: DEFAULTS.wake,
|
||
bedtime: DEFAULTS.bedtime,
|
||
schedule: DEFAULTS.schedule.map(function (x) { return Object.assign({}, x); }),
|
||
habits: DEFAULTS.habits.map(function (x) { return { id: x.id, name: x.name, note: x.note, goal: x.goal, done: x.done.concat() }; }),
|
||
log: {}
|
||
};
|
||
seedDemoDays();
|
||
applyLocal();
|
||
bind();
|
||
renderAll();
|
||
fetch("api/state")
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (data) {
|
||
if (data && data.log && Object.keys(data.log).length) {
|
||
state.wake = data.wake || state.wake;
|
||
state.bedtime = data.bedtime || state.bedtime;
|
||
state.schedule = data.schedule && data.schedule.length ? data.schedule : state.schedule;
|
||
state.habits = data.habits ? data.habits : state.habits;
|
||
state.log = Object.assign(freshLog(), data.log);
|
||
serverLogReady = true;
|
||
saveLocal();
|
||
renderAll();
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
})();
|
||
})();
|