Files
Balance/BalanceServer/internal/httpapi/web/app.js
T
2026-07-14 16:20:39 +07:00

1059 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
})[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) : `<span class="symbol">${escapeHTML(iconGlyph(item.icon))}</span>`;
return `<span class="category-badge color-${safeColor(item.colorName)} ${size}">${visual}</span>`;
}
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 `<header class="page-header"><div><p class="eyebrow">BALANCE</p><h1>${escapeHTML(title)}</h1><p>${escapeHTML(subtitle)}</p></div>${actions ? `<div class="page-actions">${actions}</div>` : ""}</header>`;
}
function emptyState(icon, title, text, action = "") {
return `<div class="empty-state"><div><span class="empty-icon">${escapeHTML(icon)}</span><h3>${escapeHTML(title)}</h3><p>${escapeHTML(text)}</p>${action}</div></div>`;
}
function render() {
if (!state.user) return;
const renderers = {
dashboard: renderDashboard,
transactions: renderTransactions,
analytics: renderAnalytics,
budgets: renderBudgets,
categories: renderCategories,
settings: renderSettings,
};
$("#content").innerHTML = (renderers[state.route] || renderDashboard)();
$$('[data-route]').forEach(button => button.classList.toggle("is-active", button.dataset.route === state.route));
document.title = `${routeTitle(state.route)} — Balance`;
}
function routeTitle(route) {
return ({ dashboard: "Обзор", transactions: "Операции", analytics: "Аналитика", budgets: "Бюджеты", categories: "Категории", settings: "Настройки" })[route] || "Balance";
}
function renderDashboard() {
const summary = summaryFor(1);
const recent = allTransactions().sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 6);
const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0;
return `${pageHeader("Обзор", "Главное о ваших финансах за текущий месяц", `
<button class="button" type="button" data-action="sync">↻ Синхронизировать</button>
<button class="button button-primary" type="button" data-action="transaction-new">+ Добавить операцию</button>`)}
<div class="grid dashboard-grid">
<section class="card hero-balance">
<p class="hero-label">Общий баланс</p>
<div class="hero-value">${formatMoney(totalBalance())}</div>
<div class="hero-meta">
<div><span>Доходы за месяц</span><strong>+ ${formatMoney(summary.income)}</strong></div>
<div><span>Расходы за месяц</span><strong>− ${formatMoney(summary.expenses)}</strong></div>
</div>
<button class="button" type="button" data-action="balance-adjust">≡ Скорректировать баланс</button>
</section>
<div class="grid grid-2 metric-grid-mobile">
<section class="card metric-card is-income"><span>Доходы</span><strong>${formatMoney(summary.income)}</strong><div class="metric-foot">Текущий месяц</div></section>
<section class="card metric-card is-expense"><span>Расходы</span><strong>${formatMoney(summary.expenses)}</strong><div class="metric-foot">Без корректировок</div></section>
<section class="card metric-card is-primary"><span>Сбережения</span><strong>${formatMoney(summary.income - summary.expenses)}</strong><div class="metric-foot">Доходы минус расходы</div></section>
<section class="card metric-card"><span>Норма сбережений</span><strong>${Number.isFinite(savings) ? `${Math.round(savings)}%` : "—"}</strong><div class="metric-foot">За текущий месяц</div></section>
</div>
<section class="card span-2">
<div class="card-header"><h2>Последние операции</h2><button class="button button-ghost" type="button" data-route="transactions">Все операции →</button></div>
<div class="card-body">${recent.length ? `<div class="list">${recent.map(transactionRow).join("")}</div>` : emptyState("↕", "Операций пока нет", "Добавьте первый доход или расход, и он появится на всех ваших устройствах.", `<button class="button button-primary" type="button" data-action="transaction-new">Добавить операцию</button>`)}</div>
</section>
</div>`;
}
function transactionRow(transaction) {
const income = transaction.kindRawValue === "income";
const item = {
emoji: transaction.categoryEmoji, icon: transaction.categoryIcon,
colorName: transaction.categoryColorName,
};
const category = transaction.isBalanceAdjustment ? "Корректировка" : transaction.categoryName;
const note = transaction.note ? ` · ${escapeHTML(transaction.note)}` : "";
return `<div class="list-row">
${categoryBadge(item)}
<div class="row-main"><strong>${escapeHTML(category)}</strong><span>${formatDate(transaction.date)}${note}</span></div>
<div class="row-value"><strong class="${income ? "amount-income" : "amount-expense"}">${income ? "+" : "−"} ${formatMoney(transaction.amount)}</strong><span>${income ? "Доход" : "Расход"}</span></div>
<div class="row-actions">
<button class="icon-button" type="button" data-action="transaction-edit" data-id="${escapeHTML(transaction.id)}" aria-label="Редактировать">✎</button>
<button class="icon-button is-danger" type="button" data-action="transaction-delete" data-id="${escapeHTML(transaction.id)}" aria-label="Удалить">×</button>
</div>
</div>`;
}
function filteredTransactions() {
const query = state.transactionQuery.trim().toLocaleLowerCase("ru");
return allTransactions()
.filter(item => state.transactionKind === "all" || item.kindRawValue === state.transactionKind)
.filter(item => !query || `${item.categoryName} ${item.note || ""}`.toLocaleLowerCase("ru").includes(query))
.sort((a, b) => new Date(b.date) - new Date(a.date));
}
function transactionListHTML() {
const items = filteredTransactions();
return items.length ? `<div class="list">${items.map(transactionRow).join("")}</div>` : emptyState("⌕", "Ничего не найдено", "Измените поиск или фильтр, либо добавьте новую операцию.");
}
function renderTransactions() {
return `${pageHeader("Операции", "Доходы, расходы и ручные корректировки баланса", `<button class="button button-primary" type="button" data-action="transaction-new">+ Добавить операцию</button>`)}
<div class="toolbar">
<div class="search-wrap"><input id="transaction-search" class="search-control" type="search" placeholder="Поиск по категории и заметке" value="${escapeHTML(state.transactionQuery)}" aria-label="Поиск операций"></div>
<select id="transaction-filter" class="select-control" aria-label="Тип операции">
<option value="all" ${state.transactionKind === "all" ? "selected" : ""}>Все операции</option>
<option value="expense" ${state.transactionKind === "expense" ? "selected" : ""}>Расходы</option>
<option value="income" ${state.transactionKind === "income" ? "selected" : ""}>Доходы</option>
</select>
</div>
<section class="card operations-card"><div id="transaction-list" class="card-body">${transactionListHTML()}</div></section>`;
}
function renderAnalytics() {
const months = state.analyticsMonths;
const summary = summaryFor(months);
const breakdown = spendingBreakdown(months);
const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0;
const max = Math.max(...breakdown.map(item => item.amount), 1);
const periodName = months === 1 ? "месяц" : `${months} месяца`;
const insight = summary.income === 0
? "Добавьте доходы, чтобы увидеть норму сбережений и персональную подсказку."
: savings >= 20
? `Вы сохраняете ${Math.round(savings)}% доходов — это сильный результат.`
: savings >= 0
? `Вы сохраняете ${Math.round(savings)}% доходов. Попробуйте постепенно приблизиться к 20%.`
: "Расходы выше доходов за выбранный период. Проверьте самые крупные категории.";
return `<div class="analytics-page">
${pageHeader("Аналитика", "Структура расходов и динамика сбережений", `<button class="button" type="button" data-action="sync">↻ Обновить</button>`)}
<div class="segmented period-switch">
<button type="button" data-analytics-months="1" class="${months === 1 ? "is-active" : ""}">Месяц</button>
<button type="button" data-analytics-months="3" class="${months === 3 ? "is-active" : ""}">3 месяца</button>
<button type="button" data-analytics-months="12" class="${months === 12 ? "is-active" : ""}">12 месяцев</button>
</div>
<div class="grid grid-4 metric-grid-mobile analytics-metrics">
<section class="card metric-card is-income"><span>Доходы</span><strong>${formatMoney(summary.income)}</strong><div class="metric-foot">За ${periodName}</div></section>
<section class="card metric-card is-expense"><span>Расходы</span><strong>${formatMoney(summary.expenses)}</strong><div class="metric-foot">За ${periodName}</div></section>
<section class="card metric-card is-primary"><span>Сбережения</span><strong>${formatMoney(summary.income - summary.expenses)}</strong><div class="metric-foot">Чистый результат</div></section>
<section class="card metric-card"><span>Норма</span><strong>${summary.income ? `${Math.round(savings)}%` : "—"}</strong><div class="metric-foot">От доходов</div></section>
</div>
<div class="grid analytics-layout">
<section class="card analytics-breakdown-card">
<div class="card-header"><h2>Расходы по категориям</h2><span class="field-label">${formatMoney(summary.expenses)}</span></div>
<div class="card-body">${breakdown.length ? `<div class="breakdown">${breakdown.map(item => `
<div class="breakdown-row">
<div class="breakdown-name">${categoryBadge(item, "small")}<span>${escapeHTML(item.name)}</span></div>
<progress class="progress-${safeColor(item.colorName)}" max="${max}" value="${Math.max(0, item.amount)}"></progress>
<div class="breakdown-value">${formatMoney(item.amount)}</div>
</div>`).join("")}</div>` : emptyState("◫", "Недостаточно данных", "В выбранном периоде ещё нет расходов.")}</div>
</section>
<aside class="card insight-card"><span class="insight-icon">✦</span><h3>Финансовый итог</h3><p>${escapeHTML(insight)}</p></aside>
</div>
</div>`;
}
function renderBudgets() {
const [start, end] = monthBounds(1);
const spending = new Map();
for (const transaction of allTransactions().filter(item => item.kindRawValue === "expense" && !item.isBalanceAdjustment && inPeriod(item, start, end))) {
spending.set(transaction.categoryName, (spending.get(transaction.categoryName) || 0) + Number(transaction.amount || 0));
}
const budgets = allBudgets().filter(item => {
const date = new Date(item.monthStart);
const now = new Date();
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && Number(item.limit) > 0;
}).sort((a, b) => a.categoryName.localeCompare(b.categoryName, "ru"));
const content = budgets.length ? budgets.map(budget => {
const value = spending.get(budget.categoryName) || 0;
const ratio = Math.min(value / Math.max(Number(budget.limit), 1), 1);
const over = value > Number(budget.limit);
const item = { emoji: budget.categoryEmoji, icon: budget.categoryIcon, colorName: "indigo" };
return `<section class="card budget-item ${over ? "is-over" : ""}">
<div class="budget-top">${categoryBadge(item)}<div class="budget-copy"><strong>${escapeHTML(budget.categoryName)}</strong><span>${over ? "Лимит превышен" : `Осталось ${formatMoney(Math.max(Number(budget.limit) - value, 0))}`}</span></div>
<div class="budget-values"><strong>${formatMoney(value)}</strong><span>из ${formatMoney(Number(budget.limit))}</span></div></div>
<progress max="1" value="${ratio}"></progress>
</section>`;
}).join("") : emptyState("◎", "Лимиты не настроены", "Задайте месячный лимит для одной или нескольких категорий расходов.", `<button class="button button-primary" type="button" data-action="budgets-edit">Настроить бюджеты</button>`);
return `${pageHeader("Бюджеты", "Месячные лимиты по категориям расходов", `<button class="button button-primary" type="button" data-action="budgets-edit">✎ Настроить лимиты</button>`)}
<div class="budget-list">${content}</div>`;
}
function renderCategories() {
const categories = allCategories().sort((a, b) => a.name.localeCompare(b.name, "ru"));
return `${pageHeader("Категории", "Собственные категории синхронизируются со всеми устройствами", `<button class="button button-primary" type="button" data-action="category-new">+ Добавить категорию</button>`)}
${categories.length ? `<div class="grid category-grid">${categories.map(category => `<section class="card category-card">
${categoryBadge(category)}
<div class="row-main"><strong>${escapeHTML(category.name)}</strong><span>${category.kindRawValue === "income" ? "доход" : "расход"}</span></div>
<div class="row-actions">
<button class="icon-button" type="button" data-action="category-edit" data-id="${escapeHTML(category.id)}" aria-label="Редактировать">✎</button>
<button class="icon-button is-danger" type="button" data-action="category-delete" data-id="${escapeHTML(category.id)}" aria-label="Удалить">×</button>
</div>
</section>`).join("")}</div>` : `<section class="card">${emptyState("◆", "Своих категорий пока нет", "Создайте категорию с одной из 50 иконок или собственным эмодзи.", `<button class="button button-primary" type="button" data-action="category-new">Добавить категорию</button>`)}</section>`}`;
}
function renderSettings() {
return `${pageHeader("Настройки", "Отображение, синхронизация и аккаунт")}
<div class="grid settings-layout">
<div class="grid">
<section class="card settings-section">
<h2>Отображение</h2><p>Настройки сохраняются только в этом браузере.</p>
<div class="settings-row"><div><strong>Валюта</strong><span>Используется для форматирования сумм</span></div>
<select id="currency-setting" class="select-control" aria-label="Валюта">${["RUB", "EUR", "USD", "SEK"].map(value => `<option ${preferences.currency === value ? "selected" : ""}>${value}</option>`).join("")}</select></div>
<div class="settings-row"><div><strong>Тема</strong><span>Светлая, тёмная или системная</span></div>
<select id="theme-setting" class="select-control" aria-label="Тема">
<option value="system" ${preferences.theme === "system" ? "selected" : ""}>Системная</option>
<option value="light" ${preferences.theme === "light" ? "selected" : ""}>Светлая</option>
<option value="dark" ${preferences.theme === "dark" ? "selected" : ""}>Тёмная</option>
</select></div>
</section>
<section class="card settings-section">
<h2>Данные</h2><p>Все изменения отправляются на ваш Balance Server.</p>
<div class="settings-row"><div><strong>Пользовательские категории</strong><span>${state.categories.size} сохранено</span></div><button class="button" type="button" data-route="categories">Открыть</button></div>
<div class="settings-row"><div><strong>Синхронизация</strong><span>Последняя: ${state.lastSyncAt ? formatDateTime(state.lastSyncAt) : "в этой сессии не выполнялась"}</span></div><button class="button" type="button" data-action="sync">Синхронизировать</button></div>
<div class="settings-row"><div><strong>Записи</strong><span>${state.transactions.size} операций · ${state.budgets.size} бюджетов</span></div></div>
</section>
</div>
<aside class="card account-card">
<div class="account-cover"></div>
<div class="account-content">
<div class="avatar">${escapeHTML((state.user.email || "Б")[0].toUpperCase())}</div>
<h3>${escapeHTML(state.user.email)}</h3><p>Аккаунт на этом self-hosted сервере</p>
<div class="status-line"><span></span><span>Соединение с сервером установлено</span></div>
<button class="button button-danger" type="button" data-action="logout">Выйти из аккаунта</button>
</div>
</aside>
</div>`;
}
function buildCategoryOptions(kind, selected) {
return categoriesFor(kind).map(category => `<option value="${escapeHTML(category.name)}" ${category.name === selected ? "selected" : ""}>${escapeHTML(category.emoji ? `${category.emoji} ${category.name}` : category.name)}</option>`).join("");
}
function modalShell(title, body, saveLabel = "Сохранить", form = "editor-form") {
return `<form id="${form}" class="modal-shell">
<header class="modal-header"><h2>${escapeHTML(title)}</h2><button class="icon-button" type="button" data-action="dialog-close" aria-label="Закрыть">×</button></header>
<div class="modal-body">${body}</div>
<footer class="modal-footer"><button class="button" type="button" data-action="dialog-close">Отмена</button><button class="button button-primary" type="submit">${escapeHTML(saveLabel)}</button></footer>
</form>`;
}
function showDialog(content) {
$("#dialog-content").innerHTML = content;
const dialog = $("#editor-dialog");
if (!dialog.open) dialog.showModal();
}
function closeDialog() {
const dialog = $("#editor-dialog");
if (dialog.open) dialog.close();
$("#dialog-content").innerHTML = "";
}
function openTransactionEditor(id = null) {
const existing = id ? state.transactions.get(id) : null;
const kind = existing?.kindRawValue || "expense";
const first = categoriesFor(kind)[0];
const selected = existing?.categoryName || first.name;
const body = `
<input type="hidden" name="id" value="${escapeHTML(existing?.id || "")}">
<input id="transaction-kind" type="hidden" name="kind" value="${kind}">
<div class="segmented kind-switch" aria-label="Тип операции">
<button type="button" data-transaction-kind="expense" class="${kind === "expense" ? "is-active" : ""}">Расход</button>
<button type="button" data-transaction-kind="income" class="${kind === "income" ? "is-active" : ""}">Доход</button>
</div>
<label class="field"><span>Сумма</span><input name="amount" inputmode="decimal" required autofocus value="${escapeHTML(existing?.amount || "")}" placeholder="0"></label>
<label class="field"><span>Категория</span><select id="transaction-category" name="category" required>${buildCategoryOptions(kind, selected)}</select></label>
<label class="field"><span>Дата</span><input name="date" type="date" required value="${localDateInput(existing?.date || new Date())}"></label>
<label class="field"><span>Заметка</span><textarea name="note" maxlength="500" placeholder="Необязательно">${escapeHTML(existing?.note || "")}</textarea></label>`;
showDialog(modalShell(existing ? "Редактирование операции" : "Новая операция", body, "Сохранить", "transaction-form"));
}
function iconChoices(selected) {
return ICONS.map(([value, glyph]) => `<label title="${escapeHTML(value)}"><input type="radio" name="icon" value="${escapeHTML(value)}" ${value === selected ? "checked" : ""}><span class="choice-option">${escapeHTML(glyph)}</span></label>`).join("");
}
function emojiChoices(selected) {
return EMOJIS.map(value => `<label><input type="radio" name="emoji-choice" value="${escapeHTML(value)}" ${value === selected ? "checked" : ""}><span class="choice-option">${escapeHTML(value)}</span></label>`).join("");
}
function colorChoices(selected) {
return COLORS.map(value => `<label title="${value}"><input type="radio" name="color" value="${value}" ${value === selected ? "checked" : ""}><span class="color-swatch swatch-${value}"></span></label>`).join("");
}
function openCategoryEditor(id = null) {
const existing = id ? state.categories.get(id) : null;
const kind = existing?.kindRawValue || "expense";
const visual = existing?.emoji ? "emoji" : "icon";
const body = `
<input type="hidden" name="id" value="${escapeHTML(existing?.id || "")}">
<input id="category-visual" type="hidden" name="visual" value="${visual}">
<label class="field"><span>Название</span><input name="name" required maxlength="80" autofocus value="${escapeHTML(existing?.name || "")}" placeholder="Например, Питомцы"></label>
<label class="field"><span>Тип</span><select name="kind" ${existing ? "disabled" : ""}>
<option value="expense" ${kind === "expense" ? "selected" : ""}>Расход</option><option value="income" ${kind === "income" ? "selected" : ""}>Доход</option>
</select></label>
${existing ? `<input type="hidden" name="kind-fixed" value="${kind}">` : ""}
<div class="segmented kind-switch"><button type="button" data-category-visual="icon" class="${visual === "icon" ? "is-active" : ""}">Иконка</button><button type="button" data-category-visual="emoji" class="${visual === "emoji" ? "is-active" : ""}">Эмодзи</button></div>
<div id="icon-section" ${visual === "emoji" ? "hidden" : ""}><p class="field-label">Иконка</p><div class="choice-grid">${iconChoices(existing?.icon || "star.fill")}</div></div>
<div id="emoji-section" ${visual === "icon" ? "hidden" : ""}>
<label class="field"><span>Собственный эмодзи</span><input id="category-emoji" name="emoji" maxlength="32" value="${escapeHTML(existing?.emoji || "")}" placeholder="Например, 🐕"></label>
<div class="choice-grid">${emojiChoices(existing?.emoji || "")}</div>
</div>
<div><p class="field-label">Цвет</p><div class="color-grid">${colorChoices(existing?.colorName || "indigo")}</div></div>
<p class="helper-text">При изменении категории её название, иконка и цвет обновятся в связанных операциях и бюджетах.</p>`;
showDialog(modalShell(existing ? "Редактирование категории" : "Новая категория", body, "Сохранить", "category-form"));
}
function openBudgetEditor() {
const categories = categoriesFor("expense");
const rows = categories.map((category, index) => {
const budget = currentBudget(category.name);
return `<div class="budget-edit-row">${categoryBadge(category, "small")}<strong>${escapeHTML(category.name)}</strong><label class="field"><input name="budget-${index}" inputmode="decimal" value="${escapeHTML(budget?.limit || "")}" placeholder="0"><input type="hidden" name="category-${index}" value="${escapeHTML(category.name)}"></label></div>`;
}).join("");
const body = `<p class="helper-text">Укажите месячный лимит. Пустое поле или ноль удаляет существующий бюджет.</p><div class="budget-edit-list">${rows}</div>`;
showDialog(modalShell("Месячные лимиты", body, "Сохранить", "budget-form"));
}
function openBalanceAdjustment() {
const current = totalBalance();
const body = `<p class="helper-text">Расчётный баланс: <strong>${formatMoney(current)}</strong>. Корректировка не влияет на аналитику.</p>
<label class="field"><span>Фактический баланс</span><input name="target" inputmode="decimal" required autofocus value="${escapeHTML(String(Math.round(current * 100) / 100))}"></label>
<label class="field"><span>Комментарий</span><textarea name="note" maxlength="500" placeholder="Ручная корректировка баланса"></textarea></label>`;
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();