"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const COLORS = [
"red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue",
"indigo", "purple", "pink", "gray", "coral", "peach", "gold", "lime",
"forest", "turquoise", "sky", "navy", "lavender", "magenta", "brown", "slate",
];
const ICONS = [
["star.fill", "★"], ["heart.fill", "♥"], ["sparkles", "✦"], ["bolt.fill", "ϟ"], ["flame.fill", "♨"],
["drop.fill", "●"], ["cart.fill", "🛒"], ["bag.fill", "▣"], ["creditcard.fill", "▤"], ["banknote.fill", "₽"],
["gift.fill", "🎁"], ["tag.fill", "◇"], ["cup.and.saucer.fill", "☕"], ["fork.knife", "♨"], ["takeoutbag.and.cup.and.straw.fill", "🥡"],
["birthday.cake.fill", "🎂"], ["car.fill", "🚗"], ["bus.fill", "🚌"], ["tram.fill", "🚊"], ["bicycle", "🚲"],
["airplane", "✈"], ["fuelpump.fill", "⛽"], ["house.fill", "⌂"], ["bed.double.fill", "▰"], ["lightbulb.fill", "☀"],
["wifi", "⌁"], ["phone.fill", "▯"], ["printer.fill", "▤"], ["pawprint.fill", "🐾"], ["leaf.fill", "♧"],
["figure.walk", "♙"], ["figure.run", "➜"], ["dumbbell.fill", "↔"], ["cross.case.fill", "+"], ["pills.fill", "◉"],
["stethoscope", "∿"], ["book.fill", "▥"], ["graduationcap.fill", "⌃"], ["laptopcomputer", "▰"], ["briefcase.fill", "▣"],
["gamecontroller.fill", "⌘"], ["music.note", "♫"], ["film.fill", "▦"], ["camera.fill", "◉"], ["paintpalette.fill", "◒"],
["tshirt.fill", "♜"], ["hammer.fill", "⚒"], ["wrench.and.screwdriver.fill", "⚙"], ["shippingbox.fill", "⬡"], ["globe.europe.africa.fill", "◎"],
];
const EMOJIS = [
"🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮",
"🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁",
"💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️",
];
const BUILT_IN = {
expense: [
["Продукты", "cart.fill", "orange"], ["Транспорт", "car.fill", "blue"],
["Дом", "house.fill", "indigo"], ["Развлечения", "gamecontroller.fill", "pink"],
["Здоровье", "cross.case.fill", "red"], ["Покупки", "bag.fill", "purple"],
["Подписки", "repeat", "cyan"], ["Образование", "book.fill", "mint"],
["Другое", "ellipsis.circle.fill", "gray"],
],
income: [
["Зарплата", "banknote.fill", "green"], ["Подработка", "laptopcomputer", "teal"],
["Инвестиции", "chart.line.uptrend.xyaxis", "blue"], ["Подарок", "gift.fill", "pink"],
["Другое", "plus.circle.fill", "gray"],
],
};
const ICON_GLYPHS = new Map(ICONS);
const EXTRA_GLYPHS = {
"repeat": "↻", "ellipsis.circle.fill": "•••", "plus.circle.fill": "+",
"laptopcomputer": "▰", "chart.line.uptrend.xyaxis": "↗", "tune": "≡",
};
const state = {
user: null,
route: "dashboard",
transactions: new Map(),
categories: new Map(),
budgets: new Map(),
cursor: 0,
syncing: false,
analyticsMonths: 1,
transactionQuery: "",
transactionKind: "all",
authMode: "login",
lastSyncAt: null,
};
const preferences = loadPreferences();
const mediaDark = window.matchMedia("(prefers-color-scheme: dark)");
class APIError extends Error {
constructor(message, code, status) {
super(message);
this.name = "APIError";
this.code = code;
this.status = status;
}
}
function loadPreferences() {
try {
const value = JSON.parse(localStorage.getItem("balance.web.preferences") || "{}");
return {
currency: ["RUB", "EUR", "USD", "SEK"].includes(value.currency) ? value.currency : "RUB",
theme: ["system", "light", "dark"].includes(value.theme) ? value.theme : "system",
};
} catch {
return { currency: "RUB", theme: "system" };
}
}
function savePreferences() {
localStorage.setItem("balance.web.preferences", JSON.stringify(preferences));
}
function applyTheme() {
const dark = preferences.theme === "dark" || (preferences.theme === "system" && mediaDark.matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
$("meta[name='theme-color']")?.setAttribute("content", dark ? "#1b1b21" : "#5b5bd6");
}
function escapeHTML(value) {
return String(value ?? "").replace(/[&<>"']/g, character => ({
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
})[character]);
}
function safeColor(value) {
return COLORS.includes(value) ? value : "indigo";
}
function iconGlyph(value) {
return ICON_GLYPHS.get(value) || EXTRA_GLYPHS[value] || "◆";
}
function categoryBadge(item, size = "") {
const visual = item.emoji ? escapeHTML(item.emoji) : `${escapeHTML(iconGlyph(item.icon))}`;
return `${visual}`;
}
function formatMoney(value, options = {}) {
const amount = Number.isFinite(Number(value)) ? Number(value) : 0;
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: preferences.currency,
maximumFractionDigits: Math.abs(amount % 1) < 0.0001 ? 0 : 2,
...options,
}).format(amount);
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", year: "numeric" }).format(date);
}
function formatDateTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "никогда";
return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(date);
}
function localDateInput(value = new Date()) {
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
function dateInputISO(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString();
}
function randomID() {
return crypto.randomUUID ? crypto.randomUUID() : `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(2)).join("-")}`;
}
function firstGrapheme(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "";
if (Intl.Segmenter) return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed)][0]?.segment || "";
return Array.from(trimmed)[0] || "";
}
function allTransactions() {
return [...state.transactions.values()];
}
function allCategories() {
return [...state.categories.values()];
}
function allBudgets() {
return [...state.budgets.values()];
}
function categoriesFor(kind) {
const builtIn = BUILT_IN[kind].map(([name, icon, colorName]) => ({ name, icon, emoji: "", colorName, kindRawValue: kind, builtIn: true }));
const custom = allCategories()
.filter(category => category.kindRawValue === kind)
.sort((a, b) => a.name.localeCompare(b.name, "ru"));
return [...builtIn, ...custom];
}
function monthBounds(months = 1) {
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1);
return [start.getTime(), end.getTime()];
}
function inPeriod(transaction, start, end) {
const time = new Date(transaction.date).getTime();
return Number.isFinite(time) && time >= start && time < end;
}
function totalBalance() {
return allTransactions().reduce((sum, transaction) => sum + (transaction.kindRawValue === "income" ? transaction.amount : -transaction.amount), 0);
}
function summaryFor(months = 1) {
const [start, end] = monthBounds(months);
const items = allTransactions().filter(item => !item.isBalanceAdjustment && inPeriod(item, start, end));
return {
income: items.filter(item => item.kindRawValue === "income").reduce((sum, item) => sum + item.amount, 0),
expenses: items.filter(item => item.kindRawValue === "expense").reduce((sum, item) => sum + item.amount, 0),
items,
start,
end,
};
}
function spendingBreakdown(months = 1) {
const { items } = summaryFor(months);
const groups = new Map();
for (const item of items.filter(value => value.kindRawValue === "expense")) {
const group = groups.get(item.categoryName) || {
name: item.categoryName, icon: item.categoryIcon, emoji: item.categoryEmoji,
colorName: item.categoryColorName, amount: 0,
};
group.amount += Number(item.amount) || 0;
groups.set(item.categoryName, group);
}
return [...groups.values()].sort((a, b) => b.amount - a.amount);
}
function currentBudget(categoryName) {
const now = new Date();
return allBudgets()
.filter(item => item.categoryName === categoryName)
.filter(item => {
const date = new Date(item.monthStart);
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
})
.sort((a, b) => new Date(b._updatedAt) - new Date(a._updatedAt))[0] || null;
}
function currentMonthStartISO() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
}
async function apiFetch(path, options = {}, retry = true) {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers, credentials: "same-origin" });
if (response.status === 401 && retry && path !== "/v1/auth/refresh") {
const refreshed = await fetch("/v1/auth/refresh", {
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: "" }),
});
if (refreshed.ok) return apiFetch(path, options, false);
}
let payload = null;
if (response.status !== 204) {
const type = response.headers.get("Content-Type") || "";
payload = type.includes("application/json") ? await response.json().catch(() => null) : await response.text().catch(() => "");
}
if (!response.ok) {
const code = payload?.error?.code || "request_failed";
throw new APIError(localizedError(code, payload?.error?.message), code, response.status);
}
return payload;
}
function localizedError(code, fallback) {
const messages = {
invalid_credentials: "Неверный email или пароль.",
invalid_email: "Введите корректный email.",
invalid_password: "Пароль должен содержать не менее 8 символов.",
email_exists: "Аккаунт с таким email уже существует.",
registration_disabled: "Регистрация на этом сервере отключена.",
rate_limited: "Слишком много попыток. Повторите через минуту.",
authorization_required: "Необходимо войти в аккаунт.",
invalid_token: "Сессия истекла. Войдите снова.",
invalid_refresh_token: "Сессия истекла. Войдите снова.",
sync_failed: "Не удалось синхронизировать данные.",
};
return messages[code] || fallback || "Не удалось выполнить запрос.";
}
function applyChanges(changes) {
for (const change of [...(changes || [])].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))) {
const target = change.entity === "transaction" ? state.transactions : change.entity === "category" ? state.categories : change.entity === "budget" ? state.budgets : null;
if (!target) continue;
if (change.deleted) target.delete(change.id);
else if (change.payload) target.set(change.id, { id: change.id, ...change.payload, _updatedAt: change.updatedAt });
}
}
async function fullSync() {
if (!state.user || state.syncing) return;
state.transactions.clear();
state.categories.clear();
state.budgets.clear();
state.cursor = 0;
await pullChanges(true);
}
async function pullChanges(silent = false) {
if (!state.user || state.syncing) return;
state.syncing = true;
setSyncStatus("syncing", "Синхронизация…");
try {
let hasMore = true;
let guard = 0;
while (hasMore) {
const previous = state.cursor;
const response = await apiFetch("/v1/sync", {
method: "POST",
body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }),
});
applyChanges(response.changes);
state.cursor = response.cursor;
hasMore = Boolean(response.hasMore);
if (hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию.");
if (++guard > 10000) throw new Error("Слишком много страниц синхронизации.");
}
state.lastSyncAt = new Date();
setSyncStatus("ok", "Синхронизировано");
render();
if (!silent) toast("Данные синхронизированы");
} catch (error) {
handleSyncError(error, silent);
throw error;
} finally {
state.syncing = false;
}
}
async function pushChanges(changes) {
if (!state.user || !changes.length) return;
if (state.syncing) throw new Error("Дождитесь завершения синхронизации.");
state.syncing = true;
setSyncStatus("syncing", "Сохранение…");
try {
for (let offset = 0; offset < changes.length; offset += 100) {
const batch = changes.slice(offset, offset + 100);
let response = await apiFetch("/v1/sync", {
method: "POST",
body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: batch, pull: true }),
});
applyChanges(response.changes);
state.cursor = response.cursor;
while (response.hasMore) {
const previous = state.cursor;
response = await apiFetch("/v1/sync", {
method: "POST",
body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }),
});
applyChanges(response.changes);
state.cursor = response.cursor;
if (response.hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию.");
}
}
state.lastSyncAt = new Date();
setSyncStatus("ok", "Синхронизировано");
render();
} catch (error) {
handleSyncError(error, false);
throw error;
} finally {
state.syncing = false;
}
}
function handleSyncError(error, silent) {
setSyncStatus("error", "Ошибка синхронизации");
if (error instanceof APIError && error.status === 401) showAuth();
if (!silent) toast(error.message || "Ошибка синхронизации", true);
}
function webDeviceID() {
let value = localStorage.getItem("balance.web.device");
if (!value) {
value = `web-${randomID()}`;
localStorage.setItem("balance.web.device", value);
}
return value;
}
function changeFor(entity, id, payload, deleted = false, updatedAt = new Date().toISOString()) {
return { entity, id, deleted, updatedAt, ...(deleted ? {} : { payload }) };
}
function transactionPayload(value) {
return {
amount: Number(value.amount), date: value.date, note: value.note || "",
categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle",
categoryEmoji: value.categoryEmoji || "", categoryColorName: value.categoryColorName || "indigo",
isBalanceAdjustment: Boolean(value.isBalanceAdjustment), kindRawValue: value.kindRawValue === "income" ? "income" : "expense",
};
}
function categoryPayload(value) {
return {
name: value.name, icon: value.icon || "star", emoji: value.emoji || "", colorName: safeColor(value.colorName),
kindRawValue: value.kindRawValue === "income" ? "income" : "expense", createdAt: value.createdAt,
};
}
function budgetPayload(value) {
return {
categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle",
categoryEmoji: value.categoryEmoji || "", limit: Number(value.limit), monthStart: value.monthStart,
};
}
function pageHeader(title, subtitle, actions = "") {
return ` BALANCE ${escapeHTML(subtitle)}${escapeHTML(title)}
${escapeHTML(text)}
${action}Общий баланс
Настройки сохраняются только в этом браузере.
Все изменения отправляются на ваш Balance Server.
Иконка
Цвет
При изменении категории её название, иконка и цвет обновятся в связанных операциях и бюджетах.
`; showDialog(modalShell(existing ? "Редактирование категории" : "Новая категория", body, "Сохранить", "category-form")); } function openBudgetEditor() { const categories = categoriesFor("expense"); const rows = categories.map((category, index) => { const budget = currentBudget(category.name); return `Укажите месячный лимит. Пустое поле или ноль удаляет существующий бюджет.
Расчётный баланс: ${formatMoney(current)}. Корректировка не влияет на аналитику.
`; showDialog(modalShell("Корректировка баланса", body, "Скорректировать", "adjustment-form")); } async function submitTransaction(form) { const data = new FormData(form); const amount = Number(String(data.get("amount")).replace(",", ".")); if (!Number.isFinite(amount) || amount <= 0) throw new Error("Введите сумму больше нуля."); const kind = data.get("kind") === "income" ? "income" : "expense"; const category = categoriesFor(kind).find(item => item.name === data.get("category")); if (!category) throw new Error("Выберите категорию."); const existing = state.transactions.get(String(data.get("id"))) || null; const id = existing?.id || randomID(); const value = { amount, date: dateInputISO(String(data.get("date"))), note: String(data.get("note") || "").trim(), categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", categoryColorName: category.colorName, isBalanceAdjustment: false, kindRawValue: kind, }; await pushChanges([changeFor("transaction", id, transactionPayload(value))]); closeDialog(); toast(existing ? "Операция обновлена" : "Операция добавлена"); } async function submitCategory(form) { const data = new FormData(form); const existing = state.categories.get(String(data.get("id"))) || null; const name = String(data.get("name") || "").trim(); const kind = String(existing?.kindRawValue || data.get("kind-fixed") || data.get("kind")) === "income" ? "income" : "expense"; if (!name) throw new Error("Введите название категории."); const duplicate = allCategories().some(item => item.id !== existing?.id && item.kindRawValue === kind && item.name.localeCompare(name, "ru", { sensitivity: "accent" }) === 0); if (duplicate) throw new Error("Категория с таким названием уже существует."); const visual = data.get("visual") === "emoji" ? "emoji" : "icon"; const emoji = visual === "emoji" ? firstGrapheme(data.get("emoji") || data.get("emoji-choice")) : ""; if (visual === "emoji" && !emoji) throw new Error("Введите или выберите эмодзи."); const now = new Date().toISOString(); const value = { name, icon: String(data.get("icon") || existing?.icon || "star.fill"), emoji, colorName: safeColor(String(data.get("color") || "indigo")), kindRawValue: kind, createdAt: existing?.createdAt || now, }; const changes = [changeFor("category", existing?.id || randomID(), categoryPayload(value), false, now)]; if (existing) { for (const transaction of allTransactions().filter(item => item.categoryName === existing.name && item.kindRawValue === kind)) { changes.push(changeFor("transaction", transaction.id, transactionPayload({ ...transaction, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, categoryColorName: value.colorName, }), false, now)); } for (const budget of allBudgets().filter(item => item.categoryName === existing.name)) { changes.push(changeFor("budget", budget.id, budgetPayload({ ...budget, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, }), false, now)); } } await pushChanges(changes); closeDialog(); toast(existing ? "Категория обновлена" : "Категория добавлена"); } async function submitBudgets(form) { const data = new FormData(form); const categories = categoriesFor("expense"); const now = new Date().toISOString(); const changes = []; categories.forEach((category, index) => { const amount = Number(String(data.get(`budget-${index}`) || "0").replace(",", ".")); const existing = currentBudget(category.name); if (Number.isFinite(amount) && amount > 0) { changes.push(changeFor("budget", existing?.id || randomID(), budgetPayload({ categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", limit: amount, monthStart: existing?.monthStart || currentMonthStartISO(), }), false, now)); } else if (existing) { changes.push(changeFor("budget", existing.id, null, true, now)); } }); if (changes.length) await pushChanges(changes); closeDialog(); toast("Бюджеты обновлены"); } async function submitAdjustment(form) { const data = new FormData(form); const target = Number(String(data.get("target")).replace(",", ".")); if (!Number.isFinite(target)) throw new Error("Введите фактический баланс."); const difference = target - totalBalance(); if (Math.abs(difference) < 0.005) throw new Error("Баланс уже соответствует указанной сумме."); const value = { amount: Math.abs(difference), date: new Date().toISOString(), note: String(data.get("note") || "").trim() || "Ручная корректировка баланса", categoryName: "Корректировка", categoryIcon: "tune", categoryEmoji: "", categoryColorName: "indigo", isBalanceAdjustment: true, kindRawValue: difference > 0 ? "income" : "expense", }; await pushChanges([changeFor("transaction", randomID(), transactionPayload(value))]); closeDialog(); toast("Баланс скорректирован"); } async function deleteTransaction(id) { const value = state.transactions.get(id); if (!value || !confirm(`Удалить операцию «${value.categoryName}» на сумму ${formatMoney(value.amount)}?`)) return; await pushChanges([changeFor("transaction", id, null, true)]); toast("Операция удалена"); } async function deleteCategory(id) { const value = state.categories.get(id); if (!value || !confirm(`Удалить категорию «${value.name}»? Существующие операции сохранятся.`)) return; await pushChanges([changeFor("category", id, null, true)]); toast("Категория удалена"); } function setRoute(route) { if (!["dashboard", "transactions", "analytics", "budgets", "categories", "settings"].includes(route)) return; state.route = route; render(); $("#content")?.focus({ preventScroll: true }); window.scrollTo({ top: 0, behavior: "smooth" }); } function setSyncStatus(type, text) { const pill = $("#sync-pill"); if (!pill) return; pill.classList.toggle("is-syncing", type === "syncing"); pill.classList.toggle("is-error", type === "error"); const label = $("span:last-child", pill); if (label) label.textContent = text; } function toast(message, error = false) { const item = document.createElement("div"); item.className = `toast${error ? " is-error" : ""}`; item.textContent = message; $("#toast-region").append(item); window.setTimeout(() => item.remove(), 4200); } function showApp() { $("#auth-view").hidden = true; $("#app-view").hidden = false; $("#account-email").textContent = state.user.email; $("#account-avatar").textContent = (state.user.email || "Б")[0].toUpperCase(); render(); } function showAuth() { state.user = null; state.transactions.clear(); state.categories.clear(); state.budgets.clear(); state.cursor = 0; $("#app-view").hidden = true; $("#auth-view").hidden = false; document.title = "Balance — вход"; } async function authenticate(event) { event.preventDefault(); const form = event.currentTarget; const button = $("#auth-submit"); const error = $("#auth-error"); error.hidden = true; button.disabled = true; button.textContent = state.authMode === "login" ? "Входим…" : "Создаём аккаунт…"; try { await apiFetch(`/v1/auth/${state.authMode}`, { method: "POST", body: JSON.stringify({ email: form.email.value.trim(), password: form.password.value }), }, false); state.user = await apiFetch("/v1/me"); showApp(); } catch (failure) { error.textContent = failure.message || "Не удалось войти."; error.hidden = false; return; } finally { button.disabled = false; button.textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; } try { await fullSync(); } catch (failure) { toast(failure.message || "Вход выполнен, но синхронизация пока недоступна.", true); } } function setAuthMode(mode) { state.authMode = mode === "register" ? "register" : "login"; $$('[data-auth-mode]').forEach(button => button.classList.toggle("is-active", button.dataset.authMode === state.authMode)); $("#auth-submit").textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; $("#auth-password").autocomplete = state.authMode === "login" ? "current-password" : "new-password"; $("#auth-error").hidden = true; } async function logout() { if (!confirm("Выйти из аккаунта в этом браузере?")) return; try { await apiFetch("/v1/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken: "" }) }, false); } catch { // The local session is cleared even if the server is temporarily unavailable. } showAuth(); } async function handleAction(action, id) { try { if (action === "transaction-new") openTransactionEditor(); else if (action === "transaction-edit") openTransactionEditor(id); else if (action === "transaction-delete") await deleteTransaction(id); else if (action === "category-new") openCategoryEditor(); else if (action === "category-edit") openCategoryEditor(id); else if (action === "category-delete") await deleteCategory(id); else if (action === "budgets-edit") openBudgetEditor(); else if (action === "balance-adjust") openBalanceAdjustment(); else if (action === "sync") await pullChanges(false); else if (action === "logout") await logout(); else if (action === "dialog-close") closeDialog(); } catch (error) { toast(error.message || "Не удалось выполнить действие", true); } } document.addEventListener("click", event => { const route = event.target.closest("[data-route]"); if (route) { event.preventDefault(); setRoute(route.dataset.route); return; } const authMode = event.target.closest("[data-auth-mode]"); if (authMode) { setAuthMode(authMode.dataset.authMode); return; } const analytics = event.target.closest("[data-analytics-months]"); if (analytics) { state.analyticsMonths = Number(analytics.dataset.analyticsMonths) || 1; render(); return; } const transactionKind = event.target.closest("[data-transaction-kind]"); if (transactionKind) { const kind = transactionKind.dataset.transactionKind; $("#transaction-kind").value = kind; $$('[data-transaction-kind]').forEach(button => button.classList.toggle("is-active", button.dataset.transactionKind === kind)); $("#transaction-category").innerHTML = buildCategoryOptions(kind, categoriesFor(kind)[0]?.name); return; } const categoryVisual = event.target.closest("[data-category-visual]"); if (categoryVisual) { const visual = categoryVisual.dataset.categoryVisual; $("#category-visual").value = visual; $$('[data-category-visual]').forEach(button => button.classList.toggle("is-active", button.dataset.categoryVisual === visual)); $("#icon-section").hidden = visual !== "icon"; $("#emoji-section").hidden = visual !== "emoji"; return; } const emojiChoice = event.target.closest('input[name="emoji-choice"]'); if (emojiChoice && $("#category-emoji")) $("#category-emoji").value = emojiChoice.value; const action = event.target.closest("[data-action]"); if (action) void handleAction(action.dataset.action, action.dataset.id); }); document.addEventListener("input", event => { if (event.target.id === "transaction-search") { state.transactionQuery = event.target.value; const list = $("#transaction-list"); if (list) list.innerHTML = transactionListHTML(); } if (event.target.id === "category-emoji") { $$('input[name="emoji-choice"]').forEach(input => { input.checked = input.value === event.target.value; }); } }); document.addEventListener("change", event => { if (event.target.id === "transaction-filter") { state.transactionKind = event.target.value; const list = $("#transaction-list"); if (list) list.innerHTML = transactionListHTML(); } else if (event.target.id === "currency-setting") { preferences.currency = event.target.value; savePreferences(); render(); } else if (event.target.id === "theme-setting") { preferences.theme = event.target.value; savePreferences(); applyTheme(); render(); } else if (event.target.name === "emoji-choice" && $("#category-emoji")) { $("#category-emoji").value = event.target.value; } }); document.addEventListener("submit", event => { if (event.target.id === "auth-form") return; event.preventDefault(); const form = event.target; const submit = $('button[type="submit"]', form); if (submit) submit.disabled = true; const actions = { "transaction-form": submitTransaction, "category-form": submitCategory, "budget-form": submitBudgets, "adjustment-form": submitAdjustment, }; const action = actions[form.id]; if (!action) return; void action(form).catch(error => { toast(error.message || "Проверьте введённые данные", true); if (submit) submit.disabled = false; }); }); $("#auth-form").addEventListener("submit", authenticate); $("#editor-dialog").addEventListener("click", event => { if (event.target === event.currentTarget) closeDialog(); }); mediaDark.addEventListener("change", () => { if (preferences.theme === "system") applyTheme(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden && state.user && !state.syncing) void pullChanges(true).catch(() => {}); }); window.setInterval(() => { if (state.user && !document.hidden && !state.syncing) void pullChanges(true).catch(() => {}); }, 30_000); async function boot() { applyTheme(); if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker.js").catch(() => {}); try { state.user = await apiFetch("/v1/me"); } catch { showAuth(); return; } showApp(); try { await fullSync(); } catch (failure) { toast(failure.message || "Не удалось загрузить данные с сервера.", true); } } void boot();