Add custom categories filters and cancellations
This commit is contained in:
parent
96e7068755
commit
f596bcf491
8 changed files with 434 additions and 28 deletions
|
|
@ -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.
|
||||
|
|
|
|||
70
app/bot.py
70
app/bot.py
|
|
@ -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)
|
||||
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
124
app/db.py
|
|
@ -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
|
||||
|
|
|
|||
33
app/main.py
33
app/main.py
|
|
@ -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)
|
||||
|
|
|
|||
143
static/app.js
143
static/app.js
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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": "Подписки"}]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue