Add custom categories filters and cancellations

This commit is contained in:
Codex 2026-09-03 19:59:26 +00:00
parent 96e7068755
commit f596bcf491
8 changed files with 434 additions and 28 deletions

View file

@ -34,7 +34,11 @@ Caddy автоматически получает TLS-сертификат. WebA
- `GET /api/healthz` — проверка состояния;
- `GET /api/leaderboard?limit=100` — общий рейтинг;
- `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей;
- `GET /api/leaderboard?category=Продукты` — рейтинг по категории;
- `GET /api/categories` — категории, встречающиеся у участников;
- `GET /api/users/{id}/summary` — публичная сводка пользователя;
- `GET /api/users/{id}/categories` — сохранённые категории пользователя;
- `GET /api/users/{id}/transactions` — активные записи пользователя;
- `GET /api/users/{id}/friends` — Telegram ID друзей;
- `GET /api/avatars/{id}` — сохранённый аватар;
- `GET /api/docs` — OpenAPI UI.

View file

@ -23,11 +23,25 @@ def webapp_url_for_user(settings: Settings, user_id: int) -> str:
return f"{settings.webapp_url}?user_id={user_id}&mode=keyboard"
def parse_transaction(raw: str) -> TransactionInput:
def decode_payload(raw: str) -> dict[str, Any]:
try:
payload: dict[str, Any] = json.loads(raw)
payload = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError("Не удалось прочитать данные") from exc
if not isinstance(payload, dict):
raise ValueError("Неизвестный формат данных")
return payload
def parse_category_name(value: Any) -> str:
category = str(value or "").strip()
if not CATEGORY_PATTERN.fullmatch(category):
raise ValueError("Категория должна содержать от 1 до 40 символов")
return category
def parse_transaction(raw: str) -> TransactionInput:
payload = decode_payload(raw)
if payload.get("type") != "transaction" or payload.get("v") != 1:
raise ValueError("Неизвестный формат данных")
@ -36,9 +50,7 @@ def parse_transaction(raw: str) -> TransactionInput:
if kind not in {"income", "expense"}:
raise ValueError("Некорректный тип операции")
category = str(payload.get("category", "")).strip()
if not CATEGORY_PATTERN.fullmatch(category):
raise ValueError("Категория должна содержать от 1 до 40 символов")
category = parse_category_name(payload.get("category"))
note = str(payload.get("note", "")).strip() or None
if note and len(note) > 160:
@ -61,6 +73,26 @@ def parse_transaction(raw: str) -> TransactionInput:
)
def parse_category(raw: str) -> tuple[str, str]:
payload = decode_payload(raw)
if payload.get("type") != "category_create" or payload.get("v") != 1:
raise ValueError("Неизвестный формат данных")
kind = payload.get("kind")
if kind not in {"income", "expense"}:
raise ValueError("Некорректный тип категории")
return kind, parse_category_name(payload.get("category"))
def parse_cancellation(raw: str) -> int:
payload = decode_payload(raw)
if payload.get("type") != "transaction_cancel" or payload.get("v") != 1:
raise ValueError("Неизвестный формат данных")
transaction_id = payload.get("transaction_id")
if not isinstance(transaction_id, int) or transaction_id <= 0:
raise ValueError("Некорректный ID записи")
return transaction_id
class TelegramBotService:
def __init__(self, settings: Settings, database: Database):
self.settings = settings
@ -115,8 +147,31 @@ class TelegramBotService:
return
self._save_user(user)
try:
item = parse_transaction(message.web_app_data.data)
self.database.add_transaction(user.id, item)
raw = message.web_app_data.data
action = decode_payload(raw).get("type")
if action == "transaction":
item = parse_transaction(raw)
self.database.add_transaction(user.id, item)
operation = "Доход" if item.kind == "income" else "Расход"
response = (
f"{operation} сохранён в категории «{item.category}». "
"Откройте приложение снова, чтобы увидеть обновлённую статистику."
)
elif action == "category_create":
kind, category = parse_category(raw)
created = self.database.add_category(user.id, kind, category)
response = (
f"Категория «{category}» добавлена."
if created
else f"Категория «{category}» уже существует."
)
elif action == "transaction_cancel":
transaction_id = parse_cancellation(raw)
if not self.database.cancel_transaction(user.id, transaction_id):
raise ValueError("Запись не найдена или уже отменена")
response = "Запись отменена и больше не участвует в расчётах."
else:
raise ValueError("Неизвестный формат данных")
except ValueError as exc:
self.bot.send_message(
message.chat.id,
@ -125,10 +180,9 @@ class TelegramBotService:
)
return
operation = "Доход" if item.kind == "income" else "Расход"
self.bot.send_message(
message.chat.id,
f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.",
response,
reply_markup=self._keyboard(user.id),
)

124
app/db.py
View file

@ -33,7 +33,8 @@ CREATE TABLE IF NOT EXISTS transactions (
category TEXT NOT NULL,
note TEXT,
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
cancelled_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_transactions_user_id
@ -47,6 +48,14 @@ CREATE TABLE IF NOT EXISTS friendships (
CHECK (user_id_low < user_id_high)
);
CREATE TABLE IF NOT EXISTS user_categories (
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('income', 'expense')),
name TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, kind, name)
);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@ -95,6 +104,22 @@ class Database:
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self.connect()) as connection:
connection.executescript(SCHEMA)
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(transactions)")
}
if "cancelled_at" not in columns:
connection.execute(
"ALTER TABLE transactions ADD COLUMN cancelled_at TEXT"
)
connection.execute(
"""
INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at)
SELECT user_id, kind, category, MIN(created_at)
FROM transactions
GROUP BY user_id, kind, category
"""
)
connection.commit()
def upsert_user(
@ -149,6 +174,13 @@ class Database:
def add_transaction(self, user_id: int, item: TransactionInput) -> int:
with closing(self.connect()) as connection:
connection.execute(
"""
INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, item.kind, item.category, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO transactions (
@ -169,6 +201,70 @@ class Database:
connection.commit()
return int(cursor.lastrowid)
def add_category(self, user_id: int, kind: str, name: str) -> bool:
with closing(self.connect()) as connection:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, kind, name, utc_now()),
)
connection.commit()
return cursor.rowcount > 0
def cancel_transaction(self, user_id: int, transaction_id: int) -> bool:
with closing(self.connect()) as connection:
cursor = connection.execute(
"""
UPDATE transactions
SET cancelled_at = ?
WHERE id = ? AND user_id = ? AND cancelled_at IS NULL
""",
(utc_now(), transaction_id, user_id),
)
connection.commit()
return cursor.rowcount > 0
def transactions(self, user_id: int, limit: int = 50) -> list[dict[str, Any]]:
with closing(self.connect()) as connection:
rows = connection.execute(
"""
SELECT id, kind, amount_cents, category, note, occurred_at
FROM transactions
WHERE user_id = ? AND cancelled_at IS NULL
ORDER BY occurred_at DESC, id DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
return [dict(row) for row in rows]
def user_categories(self, user_id: int) -> list[dict[str, str]]:
with closing(self.connect()) as connection:
rows = connection.execute(
"""
SELECT kind, name
FROM user_categories
WHERE user_id = ?
ORDER BY kind, name COLLATE NOCASE
""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def global_categories(self) -> list[dict[str, str]]:
with closing(self.connect()) as connection:
rows = connection.execute(
"""
SELECT kind, name
FROM user_categories
GROUP BY kind, name
ORDER BY name COLLATE NOCASE, kind
"""
).fetchall()
return [dict(row) for row in rows]
def add_friendship(self, first_user_id: int, second_user_id: int) -> bool:
if first_user_id == second_user_id:
return False
@ -232,7 +328,8 @@ class Database:
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
FROM users u
LEFT JOIN transactions t ON t.user_id = u.user_id
LEFT JOIN transactions t
ON t.user_id = u.user_id AND t.cancelled_at IS NULL
WHERE u.user_id = ?
GROUP BY u.user_id
""",
@ -244,7 +341,7 @@ class Database:
"""
SELECT kind, category, SUM(amount_cents) AS amount_cents
FROM transactions
WHERE user_id = ?
WHERE user_id = ? AND cancelled_at IS NULL
GROUP BY kind, category
ORDER BY amount_cents DESC
""",
@ -253,16 +350,27 @@ class Database:
return self._serialize_totals(dict(row), categories)
def leaderboard(
self, *, user_ids: Iterable[int] | None = None, limit: int = 100
self,
*,
user_ids: Iterable[int] | None = None,
categories: Iterable[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
ids = list(dict.fromkeys(user_ids or []))
category_names = list(dict.fromkeys(categories or []))
where = ""
params: list[Any] = []
where_params: list[Any] = []
if ids:
placeholders = ",".join("?" for _ in ids)
where = f"WHERE u.user_id IN ({placeholders})"
params.extend(ids)
params.append(limit)
where_params.extend(ids)
join_conditions = ["t.user_id = u.user_id", "t.cancelled_at IS NULL"]
join_params: list[Any] = []
if category_names:
placeholders = ",".join("?" for _ in category_names)
join_conditions.append(f"t.category IN ({placeholders})")
join_params.extend(category_names)
params = [*join_params, *where_params, limit]
with closing(self.connect()) as connection:
rows = connection.execute(
f"""
@ -274,7 +382,7 @@ class Database:
COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents,
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
FROM users u
LEFT JOIN transactions t ON t.user_id = u.user_id
LEFT JOIN transactions t ON {' AND '.join(join_conditions)}
{where}
GROUP BY u.user_id
ORDER BY

View file

@ -75,6 +75,23 @@ def user_friends(user_id: int) -> dict[str, list[int]]:
return {"user_ids": database.friend_ids(user_id)}
@app.get("/api/users/{user_id}/categories")
def user_categories(user_id: int) -> dict[str, list[dict[str, str]]]:
return {"items": database.user_categories(user_id)}
@app.get("/api/users/{user_id}/transactions")
def user_transactions(
user_id: int, limit: int = Query(default=50, ge=1, le=100)
) -> dict[str, list[dict]]:
return {"items": database.transactions(user_id, limit)}
@app.get("/api/categories")
def global_categories() -> dict[str, list[dict[str, str]]]:
return {"items": database.global_categories()}
def parse_user_ids(raw: str | None) -> list[int] | None:
if not raw:
return None
@ -100,9 +117,23 @@ def leaderboard(
user_ids: str | None = Query(
default=None, description="Telegram user ID через запятую"
),
category: list[str] | None = Query(
default=None, description="Один или несколько фильтров по категории"
),
limit: int = Query(default=100, ge=1, le=100),
) -> dict[str, list[dict]]:
return {"items": database.leaderboard(user_ids=parse_user_ids(user_ids), limit=limit)}
categories = None
if category:
categories = list(dict.fromkeys(item.strip() for item in category if item.strip()))
if len(categories) > 20 or any(len(item) > 40 for item in categories):
raise HTTPException(status_code=422, detail="Некорректный фильтр категорий")
return {
"items": database.leaderboard(
user_ids=parse_user_ids(user_ids),
categories=categories,
limit=limit,
)
}
@app.get("/api/avatars/{user_id}", response_class=FileResponse)

View file

@ -9,7 +9,14 @@
const user = tgUser || (previewUserId ? { id: previewUserId } : null);
const userId = Number(user?.id) || 0;
const state = { kind: "income", board: "all", summary: null, config: null };
const state = {
kind: "income",
board: "all",
categoryFilter: "",
summary: null,
config: null,
customCategories: { income: [], expense: [] },
};
const categories = {
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
@ -57,7 +64,9 @@
function fillCategories() {
const select = $("#category");
select.innerHTML = categories[state.kind].map((category) => `<option value="${category}">${category}</option>`).join("");
const items = [...new Set([...categories[state.kind], ...state.customCategories[state.kind]])];
select.innerHTML = items.map((category) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")
+ '<option value="__new__"> Новая категория…</option>';
$("#customCategoryWrap").classList.add("hidden");
$("#customCategory").required = false;
}
@ -72,11 +81,12 @@
});
});
$("#category").addEventListener("change", (event) => {
const custom = event.target.value === "Другое";
const custom = event.target.value === "__new__";
$("#customCategoryWrap").classList.toggle("hidden", !custom);
$("#customCategory").required = custom;
if (custom) $("#customCategory").focus();
});
$("#saveCategoryButton").addEventListener("click", saveCategory);
$("#transactionForm").addEventListener("submit", submitTransaction);
}
@ -84,7 +94,7 @@
event.preventDefault();
const amount = Number($("#amount").value);
const selected = $("#category").value;
const category = selected === "Другое" ? $("#customCategory").value.trim() : selected;
const category = selected === "__new__" ? $("#customCategory").value.trim() : selected;
if (!Number.isFinite(amount) || amount <= 0 || !category) {
showToast("Проверьте сумму и категорию");
return;
@ -98,22 +108,47 @@
note: $("#note").value.trim().slice(0, 160),
occurred_at: new Date().toISOString(),
};
const encoded = JSON.stringify(payload);
if (encoded.length > 4096) {
showToast("Запись получилась слишком длинной");
sendTelegramPayload(payload);
}
function saveCategory() {
const category = $("#customCategory").value.trim();
if (!category) {
showToast("Введите название категории");
return;
}
sendTelegramPayload({
v: 1,
type: "category_create",
kind: state.kind,
category: category.slice(0, 40),
});
}
function canSendViaTelegram() {
const isTelegramClient = Boolean(tg?.platform && tg.platform !== "unknown");
if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) {
showToast("Отправьте /start боту и откройте приложение кнопкой под сообщением");
return;
return false;
}
return true;
}
function sendTelegramPayload(payload) {
const encoded = JSON.stringify(payload);
if (encoded.length > 4096) {
showToast("Запись получилась слишком длинной");
return false;
}
if (!canSendViaTelegram()) return false;
try {
tg.HapticFeedback?.notificationOccurred("success");
tg.sendData(encoded);
return true;
} catch (error) {
console.error("Telegram sendData failed", error);
showToast("Telegram не принял запись. Обновите приложение и попробуйте снова");
return false;
}
}
@ -129,11 +164,27 @@
state.summary = await fetchJson(`../api/users/${userId}/summary`);
setIdentity(state.summary);
renderSummary(state.summary);
await loadTransactions();
} catch (error) {
console.warn("Summary is not available", error);
}
}
async function loadUserCategories() {
if (!userId) return;
try {
const data = await fetchJson(`../api/users/${userId}/categories`);
for (const item of data.items || []) {
if (state.customCategories[item.kind] && !state.customCategories[item.kind].includes(item.name)) {
state.customCategories[item.kind].push(item.name);
}
}
fillCategories();
} catch (error) {
console.warn("User categories are not available", error);
}
}
function renderSummary(summary) {
$("#balanceValue").textContent = money.format(summary.balance_cents / 100);
$("#incomeValue").textContent = money.format(summary.income_cents / 100);
@ -164,10 +215,82 @@
return element.innerHTML;
}
async function loadTransactions() {
if (!userId) return;
try {
const data = await fetchJson(`../api/users/${userId}/transactions?limit=30`);
renderTransactions(data.items || []);
} catch (error) {
console.warn("Transactions are not available", error);
}
}
function renderTransactions(items) {
const list = $("#transactionList");
if (!items.length) {
list.className = "transaction-list empty-state";
list.textContent = "Записей пока нет.";
return;
}
list.className = "transaction-list";
list.innerHTML = items.map((item) => {
const income = item.kind === "income";
const date = new Date(item.occurred_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
const detail = [date, item.note].filter(Boolean).map(escapeHtml).join(" · ");
return `<div class="transaction-row ${income ? "" : "expense-row"}">
<span class="transaction-kind">${income ? "↗" : "↘"}</span>
<span class="transaction-copy"><strong>${escapeHtml(item.category)}</strong><small>${detail}</small></span>
<span class="transaction-actions">
<strong>${income ? "+" : ""}${money.format(item.amount_cents / 100)}</strong>
<button class="cancel-button" type="button" data-transaction-id="${item.id}">Отменить</button>
</span>
</div>`;
}).join("");
list.querySelectorAll(".cancel-button").forEach((button) => {
button.addEventListener("click", () => cancelTransaction(Number(button.dataset.transactionId)));
});
}
function cancelTransaction(transactionId) {
const send = (confirmed) => {
if (!confirmed) return;
sendTelegramPayload({ v: 1, type: "transaction_cancel", transaction_id: transactionId });
};
if (tg?.showConfirm && tg.isVersionAtLeast?.("6.2")) {
try {
tg.showConfirm("Отменить запись? Она перестанет участвовать в расчётах.", send);
return;
} catch (error) {
console.warn("Telegram confirmation failed", error);
}
}
send(window.confirm("Отменить запись? Она перестанет участвовать в расчётах."));
}
function bindLeaderboard() {
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
$("#shareButton").addEventListener("click", shareInvite);
$("#leaderboardCategory").addEventListener("change", (event) => {
state.categoryFilter = event.target.value;
loadLeaderboard();
});
loadLeaderboardCategories();
}
async function loadLeaderboardCategories() {
try {
const data = await fetchJson("../api/categories");
const names = [...new Set([
...categories.income,
...categories.expense,
...(data.items || []).map((item) => item.name),
])].sort((a, b) => a.localeCompare(b, "ru"));
$("#leaderboardCategory").innerHTML = '<option value="">Все категории</option>'
+ names.map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join("");
} catch (error) {
console.warn("Leaderboard categories are not available", error);
}
}
function switchBoard(board) {
@ -191,6 +314,9 @@
url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`;
$("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0);
}
if (state.categoryFilter) {
url += `&category=${encodeURIComponent(state.categoryFilter)}`;
}
const data = await fetchJson(url);
renderLeaderboard(data.items || []);
} catch (error) {
@ -246,4 +372,5 @@
bindLeaderboard();
fillCategories();
loadSummary();
loadUserCategories();
})();

View file

@ -8,7 +8,7 @@
<title>Пожитки</title>
<link rel="stylesheet" href="./styles.css" />
<script src="./telegram-web-app.js"></script>
<script src="./app.js?v=2" defer></script>
<script src="./app.js?v=3" defer></script>
</head>
<body>
<main class="shell">
@ -52,6 +52,14 @@
<div id="categoryList" class="category-list empty-state">
Добавьте первую операцию — категории появятся здесь.
</div>
<div class="section-title history-heading">
<h2>Последние записи</h2>
<span>можно отменить</span>
</div>
<div id="transactionList" class="transaction-list empty-state">
Записей пока нет.
</div>
</section>
<section id="addView" class="view" aria-labelledby="addTab">
@ -77,6 +85,7 @@
<label id="customCategoryWrap" class="hidden">
<span>Своя категория</span>
<input id="customCategory" maxlength="40" placeholder="Например, Фриланс" />
<button id="saveCategoryButton" class="secondary-button" type="button">Сохранить только категорию</button>
</label>
<label>
@ -98,6 +107,12 @@
<button id="allBoardButton" class="active" type="button">Все</button>
<button id="friendsBoardButton" type="button">Друзья</button>
</div>
<label class="leader-filter">
<span>Категория</span>
<select id="leaderboardCategory">
<option value="">Все категории</option>
</select>
</label>
<div id="leaderboardList" class="leaderboard-list loading">Загружаем рейтинг…</div>
<p id="friendHint" class="friend-hint hidden">Поделитесь ссылкой: когда друг запустит бота, он появится здесь.</p>
</section>

View file

@ -81,6 +81,19 @@ h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.category-item .category-bar { grid-column: 1 / -1; height: 3px; overflow: hidden; border-radius: 4px; background: var(--line); }
.category-bar i { display: block; height: 100%; background: var(--accent); }
.category-item.expense-item .category-bar i { background: var(--danger); }
.history-heading { margin-top: 28px; }
.transaction-list { overflow: hidden; border: 1px solid var(--line); border-radius: 21px; background: var(--surface); }
.transaction-row { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 11px; padding: 13px 14px; border-bottom: 1px solid var(--line); }
.transaction-row:last-child { border-bottom: 0; }
.transaction-kind { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 13px; color: var(--accent); background: color-mix(in srgb, var(--accent) 13%, transparent); font-size: 19px; }
.transaction-row.expense-row .transaction-kind { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }
.transaction-copy { min-width: 0; }
.transaction-copy strong, .transaction-copy small { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.transaction-copy strong { font-size: 13px; }
.transaction-copy small { margin-top: 3px; color: var(--muted); font-size: 10px; }
.transaction-actions { display: grid; justify-items: end; gap: 5px; }
.transaction-actions strong { font-size: 13px; }
.cancel-button { padding: 2px 0; border: 0; background: transparent; color: var(--danger); font-size: 10px; }
.add-heading { margin: 5px 3px 24px; }
.transaction-form { display: grid; gap: 18px; }
@ -96,12 +109,15 @@ h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.amount-field input { height: 78px; padding-right: 54px; font-size: 35px; font-weight: 750; letter-spacing: -.04em; }
.amount-field b { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 24px; }
.primary-button { min-height: 54px; margin-top: 3px; border: 0; border-radius: 16px; background: var(--accent); color: var(--accent-text); font-weight: 750; box-shadow: 0 10px 25px color-mix(in srgb, var(--accent) 28%, transparent); }
.secondary-button { min-height: 44px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 14px; background: color-mix(in srgb, var(--accent) 9%, var(--surface)); color: var(--accent); font-weight: 700; }
.form-hint { margin: -8px 15px 0; color: var(--muted); font-size: 10px; line-height: 1.45; text-align: center; }
.hidden { display: none !important; }
.board-heading { align-items: center; margin-top: 5px; margin-bottom: 20px; }
.icon-button { width: 42px; height: 42px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--link); font-size: 21px; }
.board-switch { margin-bottom: 13px; }
.leader-filter { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; margin: 0 3px 13px; color: var(--muted); font-size: 11px; font-weight: 700; }
.leader-filter select { width: 100%; min-height: 40px; padding: 0 12px; border: 1px solid var(--line); border-radius: 12px; outline: none; background: var(--surface); }
.leaderboard-list { overflow: hidden; min-height: 100px; border: 1px solid var(--line); border-radius: 22px; background: var(--surface); }
.leaderboard-list.loading { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; }
.leader-row { display: grid; grid-template-columns: 25px 42px 1fr auto; align-items: center; gap: 10px; padding: 13px 14px; border-bottom: 1px solid var(--line); }

View file

@ -2,7 +2,12 @@ from pathlib import Path
import pytest
from app.bot import parse_transaction, webapp_url_for_user
from app.bot import (
parse_cancellation,
parse_category,
parse_transaction,
webapp_url_for_user,
)
from app.config import Settings
from app.db import Database
@ -58,8 +63,26 @@ def test_filtered_leaderboard(tmp_path: Path) -> None:
for user_id in (1, 2, 3):
add_user(database, user_id, str(user_id))
database.add_transaction(
1,
parse_transaction(
'{"v":1,"type":"transaction","kind":"income","amount":"100","category":"Работа"}'
),
)
database.add_transaction(
3,
parse_transaction(
'{"v":1,"type":"transaction","kind":"expense","amount":"40","category":"Еда"}'
),
)
result = database.leaderboard(user_ids=[1, 3])
assert [item["user_id"] for item in result] == [1, 3]
filtered = database.leaderboard(user_ids=[1, 3], categories=["Еда"])
assert filtered[0]["user_id"] == 1
assert filtered[0]["balance_cents"] == 0
assert filtered[1]["user_id"] == 3
assert filtered[1]["balance_cents"] == -4_000
@pytest.mark.parametrize(
@ -85,3 +108,31 @@ def test_personalized_keyboard_webapp_url(tmp_path: Path) -> None:
assert webapp_url_for_user(settings, 123456) == (
"https://example.com/app/?user_id=123456&mode=keyboard"
)
def test_custom_category_and_cancellation(tmp_path: Path) -> None:
database = make_database(tmp_path)
add_user(database, 10, "Владелец")
add_user(database, 11, "Другой")
kind, name = parse_category(
'{"v":1,"type":"category_create","kind":"expense","category":"Подписки"}'
)
assert database.add_category(10, kind, name)
assert not database.add_category(10, kind, name)
assert database.user_categories(10) == [{"kind": "expense", "name": "Подписки"}]
transaction_id = database.add_transaction(
10,
parse_transaction(
'{"v":1,"type":"transaction","kind":"expense","amount":"499","category":"Подписки"}'
),
)
assert parse_cancellation(
f'{{"v":1,"type":"transaction_cancel","transaction_id":{transaction_id}}}'
) == transaction_id
assert not database.cancel_transaction(11, transaction_id)
assert database.cancel_transaction(10, transaction_id)
assert not database.cancel_transaction(10, transaction_id)
assert database.transactions(10) == []
assert database.summary(10)["expense_cents"] == 0
assert database.user_categories(10) == [{"kind": "expense", "name": "Подписки"}]