322 lines
12 KiB
JavaScript
322 lines
12 KiB
JavaScript
/* Трекер привычек — демо. Данные: демо-набор по умолчанию, правки — в localStorage. */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var STORAGE_KEY = "kt_habit_tracker:v1";
|
||
var DAY_COUNT = 7; // показываем последние 7 дней
|
||
var WEEKDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
|
||
|
||
// Словарь иконок из спрайта ДС.
|
||
var ICONS = {
|
||
book: "book", dumbbell: "tool", droplet: "layers",
|
||
moon: "moon", lightbulb: "lightbulb", check: "check",
|
||
checklist: "checklist", sparkles: "sparkles", star: "trophy",
|
||
trophy: "trophy", "check-circle": "check-circle",
|
||
run: "rocket", rocket: "rocket", walk: "arrowRight"
|
||
};
|
||
var ICON_BY_NAME_KEY = [
|
||
["чит", "book"], ["чт", "book"], ["читан", "book"],
|
||
["спорт", "tool"], ["трени", "tool"], ["бег", "rocket"], ["зал", "tool"],
|
||
["вода", "layers"], ["пить", "layers"], ["hydration", "layers"],
|
||
["сон", "moon"], ["спать", "moon"], ["sleep", "moon"],
|
||
["медит", "sparkles"], ["relax", "sparkles"],
|
||
["идея", "lightbulb"], ["мысль", "lightbulb"],
|
||
["звезд", "trophy"], ["цель", "trophy"], ["trophy", "trophy"],
|
||
["heart", "check-circle"], ["люб", "check-circle"]
|
||
];
|
||
var DEFAULT_ICON = "checklist";
|
||
|
||
var DEMO = [
|
||
{ id: "read", name: "Чтение", icon: "book" },
|
||
{ id: "sport", name: "Спорт", icon: "tool" },
|
||
{ id: "water", name: "Вода", icon: "layers" },
|
||
{ id: "sleep", name: "Ранний подъём", icon: "moon" },
|
||
{ id: "ideas", name: "Идеи на день", icon: "lightbulb"}
|
||
];
|
||
|
||
// Детерминированные демо-статусы за последние 7 дней (0 = не сделано, 1 = сделано).
|
||
var DEMO_CHECKS = {
|
||
read: [1, 1, 1, 1, 1, 1, 0],
|
||
sport: [1, 0, 1, 1, 0, 1, 0],
|
||
water: [1, 1, 1, 0, 1, 1, 1],
|
||
sleep: [1, 1, 0, 1, 1, 1, 1],
|
||
ideas: [0, 1, 1, 1, 1, 0, 1]
|
||
};
|
||
|
||
// ---- Утилиты дат (локальные, без часовых поясов) ----
|
||
function toDateStr(d) {
|
||
var y = d.getFullYear();
|
||
var m = String(d.getMonth() + 1).padStart(2, "0");
|
||
var day = String(d.getDate()).padStart(2, "0");
|
||
return y + "-" + m + "-" + day;
|
||
}
|
||
function todayStr() { return toDateStr(new Date()); }
|
||
function lastDays(n) {
|
||
var out = [];
|
||
var now = new Date();
|
||
for (var i = n - 1; i >= 0; i--) {
|
||
var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||
out.push(toDateStr(d));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- Состояние ----
|
||
function blankChecks() {
|
||
var out = {};
|
||
lastDays(DAY_COUNT).forEach(function (s) { out[s] = 0; });
|
||
return out;
|
||
}
|
||
function makeDemo() {
|
||
var days = lastDays(DAY_COUNT);
|
||
return DEMO.map(function (p) {
|
||
var checks = {};
|
||
(DEMO_CHECKS[p.id] || [0,0,0,0,0,0,0]).forEach(function (v, idx) {
|
||
checks[days[idx]] = v;
|
||
});
|
||
return { id: p.id, name: p.name, icon: p.icon, checks: checks, addedAt: days[0] };
|
||
});
|
||
}
|
||
function load() {
|
||
try {
|
||
var raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
var p = JSON.parse(raw);
|
||
if (p && typeof p === "object" && Array.isArray(p.habits) && p.habits.length) return p;
|
||
}
|
||
} catch (e) { /* повреждённое хранилище — начнём с демо */ }
|
||
var demo = { habits: makeDemo(), demo: true };
|
||
save(demo);
|
||
return demo;
|
||
}
|
||
function save(state) {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
|
||
}
|
||
|
||
var state = load();
|
||
|
||
// ---- Расчёты ----
|
||
// Серия дней подряд: считаем от самого недавнего дня до первого пропуска.
|
||
function visibleStreak(habit, days7) {
|
||
var reverse = days7.slice().reverse(); // от старого к новому
|
||
var streak = 0;
|
||
for (var i = 0; i < reverse.length; i++) {
|
||
if (habit.checks[reverse[i]]) streak++; else break;
|
||
}
|
||
return streak;
|
||
}
|
||
|
||
// ---- DOM ----
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var habitsEl = $("habits");
|
||
var emptyEl = $("emptyState");
|
||
var kpiStrip = $("kpiStrip");
|
||
var demoReset = $("demoReset");
|
||
var toastStack = $("toastStack");
|
||
|
||
function fmtDate(str) {
|
||
var p = str.split("-");
|
||
return p[2] + "." + p[1];
|
||
}
|
||
function iconUse(icon) {
|
||
return '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#' + (ICONS[icon] || icon || DEFAULT_ICON) + '"></use></svg>';
|
||
}
|
||
|
||
function render() {
|
||
var days7 = lastDays(DAY_COUNT);
|
||
var today = todayStr();
|
||
|
||
// подпись даты в шапке
|
||
var now = new Date();
|
||
$("todayLabel").textContent = WEEKDAYS[now.getDay()] + ", " + fmtDate(today) + "." + String(now.getFullYear());
|
||
|
||
var habits = state.habits;
|
||
|
||
if (!habits.length) {
|
||
habitsEl.hidden = true;
|
||
kpiStrip.style.display = "none";
|
||
emptyEl.hidden = false;
|
||
demoReset.hidden = true;
|
||
return;
|
||
}
|
||
emptyEl.hidden = true;
|
||
habitsEl.hidden = false;
|
||
kpiStrip.style.display = "";
|
||
|
||
// KPI — тихая метрик-полоса
|
||
var doneToday = habits.filter(function (h) { return h.checks[today]; }).length;
|
||
var best = 0, bestName = "";
|
||
habits.forEach(function (h) {
|
||
var s = visibleStreak(h, days7);
|
||
if (s > best) { best = s; bestName = h.name; }
|
||
});
|
||
$("kpiHabits").textContent = String(habits.length);
|
||
$("kpiDone").textContent = doneToday > 0 ? (doneToday + " / " + habits.length) : "—";
|
||
document.querySelector("#kpiDoneWrap .label").textContent = "сегодня выполнено";
|
||
$("kpiBest").textContent = best > 0 ? (best + " дн.") : "—";
|
||
var bestNameEl = $("kpiBestName");
|
||
bestNameEl.textContent = best > 0 ? bestName : "";
|
||
bestNameEl.style.display = best > 0 ? "" : "none";
|
||
|
||
// Список
|
||
habitsEl.innerHTML = habits.map(function (h) {
|
||
var st = visibleStreak(h, days7);
|
||
var daysHtml = days7.map(function (s) {
|
||
var on = h.checks[s] ? "on" : "";
|
||
var isToday = s === today ? " today" : "";
|
||
var dte = new Date(s);
|
||
var dow = WEEKDAYS[dte.getDay()];
|
||
return '<div class="day"><span class="dot ' + on + isToday + '" data-day="' + s + '"></span><span class="dow">' + dow + "</span></div>";
|
||
}).join("");
|
||
|
||
var todayDone = h.checks[today];
|
||
var toggleLabel = todayDone ? "Сегодня: выполнено" : "Отметить за сегодня";
|
||
var toggleVariant = todayDone ? "ghost" : "primary";
|
||
var toggleIcon = todayDone ? "check" : "plus";
|
||
|
||
return (
|
||
'<article class="kt-ai-card habit" data-id="' + h.id + '">' +
|
||
'<div class="habit-top">' +
|
||
'<div class="habit-ic">' + iconUse(h.icon) + "</div>" +
|
||
'<div class="habit-meta">' +
|
||
'<div class="habit-name">' + escapeHtml(h.name) + "</div>" +
|
||
'<div class="habit-sub">Последние 7 дней · серия ' + st + " " + plural(st) + "</div>" +
|
||
"</div>" +
|
||
'<div class="habit-streak"><div class="num">' + st + '</div><div class="lbl">дней подряд</div></div>' +
|
||
'<button class="ic-btn" data-act="delete" aria-label="Удалить привычку: ' + escapeHtml(h.name) + '" title="Удалить"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#trash"></use></svg></button>' +
|
||
"</div>" +
|
||
'<div class="habit-body">' +
|
||
'<div class="habit-week">' + daysHtml + "</div>" +
|
||
'<span class="spacer"></span>' +
|
||
'<button class="kt-ai-btn" data-variant="' + toggleVariant + '" data-size="sm" data-act="toggle" type="button">' +
|
||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><use href="#' + toggleIcon + '"></use></svg> ' + toggleLabel +
|
||
"</button>" +
|
||
"</div>" +
|
||
"</article>"
|
||
);
|
||
}).join("");
|
||
|
||
demoReset.hidden = state.demo !== true;
|
||
}
|
||
|
||
function plural(n) {
|
||
var m10 = n % 10, m100 = n % 100;
|
||
if (m10 === 1 && m100 !== 11) return "день";
|
||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "дня";
|
||
return "дней";
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, function (c) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||
});
|
||
}
|
||
|
||
function habitById(id) {
|
||
for (var i = 0; i < state.habits.length; i++) if (state.habits[i].id === id) return state.habits[i];
|
||
return null;
|
||
}
|
||
|
||
// ---- Действия ----
|
||
function toggleToday(id) {
|
||
var h = habitById(id);
|
||
if (!h) return;
|
||
var t = todayStr();
|
||
h.checks[t] = h.checks[t] ? 0 : 1;
|
||
state.demo = false; // правка — это уже данные приложения, не демо
|
||
save(state);
|
||
render();
|
||
toast(h.checks[t] ? "Отметили за сегодня" : "Сняли отметку за сегодня", "ok");
|
||
}
|
||
|
||
function delHabit(id) {
|
||
var h = habitById(id);
|
||
if (!h) return;
|
||
if (!confirm("Удалить привычку «" + h.name + "»?")) return;
|
||
state.habits = state.habits.filter(function (x) { return x.id !== id; });
|
||
if (state.habits.length === 0) state.demo = false;
|
||
save(state);
|
||
render();
|
||
toast("Привычка удалена", "ok");
|
||
}
|
||
|
||
function resetDemo() {
|
||
if (!confirm("Сбросить все правки и вернуть демо-данные?")) return;
|
||
state = { habits: makeDemo(), demo: true };
|
||
save(state);
|
||
render();
|
||
toast("Возвращены демо-данные", "ok");
|
||
}
|
||
|
||
// ---- Модал добавления ----
|
||
var modal = $("addModal");
|
||
var nameIn = $("addName");
|
||
var errEl = $("addError");
|
||
function openModal() {
|
||
nameIn.value = "";
|
||
errEl.hidden = true;
|
||
modal.hidden = false;
|
||
setTimeout(function () { nameIn.focus(); }, 30);
|
||
}
|
||
function closeModal() { modal.hidden = true; }
|
||
|
||
function detectIcon(name) {
|
||
var low = name.toLowerCase();
|
||
for (var i = 0; i < ICON_BY_NAME_KEY.length; i++) {
|
||
if (low.indexOf(ICON_BY_NAME_KEY[i][0]) !== -1) return ICON_BY_NAME_KEY[i][1];
|
||
}
|
||
return DEFAULT_ICON;
|
||
}
|
||
|
||
function saveNew() {
|
||
var name = nameIn.value.trim();
|
||
if (!name) { errEl.hidden = false; nameIn.setAttribute("data-invalid", "true"); return; }
|
||
errEl.hidden = true;
|
||
nameIn.removeAttribute("data-invalid");
|
||
var id = "h" + Date.now().toString(36);
|
||
var icon = detectIcon(name);
|
||
state.habits.unshift({ id: id, name: name, icon: icon, checks: blankChecks(), addedAt: todayStr() });
|
||
state.demo = false;
|
||
save(state);
|
||
closeModal();
|
||
render();
|
||
toast("Привычка «" + name + "» добавлена", "ok");
|
||
}
|
||
|
||
// ---- Тост ----
|
||
function toast(msg, tone) {
|
||
var t = document.createElement("div");
|
||
t.className = "kt-ai-toast";
|
||
if (tone) t.setAttribute("data-tone", tone);
|
||
t.textContent = msg;
|
||
toastStack.appendChild(t);
|
||
setTimeout(function () {
|
||
t.style.opacity = "0";
|
||
t.style.transition = "opacity .3s";
|
||
setTimeout(function () { t.remove(); }, 300);
|
||
}, 2200);
|
||
}
|
||
|
||
// ---- События ----
|
||
$("addBtn").addEventListener("click", openModal);
|
||
$("emptyAddBtn").addEventListener("click", openModal);
|
||
$("addCancel").addEventListener("click", closeModal);
|
||
$("addSave").addEventListener("click", saveNew);
|
||
$("resetBtn").addEventListener("click", resetDemo);
|
||
nameIn.addEventListener("keydown", function (e) { if (e.key === "Enter") saveNew(); });
|
||
modal.addEventListener("click", function (e) { if (e.target === modal) closeModal(); });
|
||
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !modal.hidden) closeModal(); });
|
||
|
||
habitsEl.addEventListener("click", function (e) {
|
||
var card = e.target.closest(".habit");
|
||
if (!card) return;
|
||
var id = card.getAttribute("data-id");
|
||
var act = e.target.closest("[data-act]");
|
||
if (!act) return;
|
||
var a = act.getAttribute("data-act");
|
||
if (a === "toggle") toggleToday(id);
|
||
else if (a === "delete") delHabit(id);
|
||
});
|
||
|
||
render();
|
||
})();
|