feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
@@ -0,0 +1,32 @@
# 2026-09-09 — Telegram-бот «от зарплаты до зарплаты»
## Запрос
Сделать бота: ввод суммы и даты «до зарплаты», расчёт дневного лимита, быстрый учёт дневных трат.
## Решение
С нуля собран Telegram-бот на **aiogram 3** + **SQLite (aiosqlite)**.
### Структура
- `bot/main.py` — точка входа, polling
- `bot/config.py``BOT_TOKEN` из `.env`
- `bot/db/` — схема, репозиторий
- `bot/services/budget.py` — лимит дня от остатка на утро (`(остаток+траты_сегодня)/дней`), модель «утреннего конверта»
- `bot/services/parsing.py` — парсинг сумм/дат/трат
- `bot/handlers/budget.py` — команды и FSM настройки бюджета
### UX
- `/budget` → сумма → дата
- Трата одним сообщением: `250`, `250 кофе`, `кофе 250`
- `/status`, `/today`, `/undo`
- Reply-клавиатура: Статус / Сегодня / Новый бюджет / Отмена траты
### Запуск
1. Токен от BotFather в `.env`
2. `pip install -r requirements.txt`
3. `python -m bot.main`
## Не сделано (можно следующим шагом)
- Категории трат и отчёты за период
- Напоминание вечером, если лимит не выбран
- Мультивалютность / несколько бюджетов
- Деплой (systemd / Docker)
+15
View File
@@ -0,0 +1,15 @@
# 2026-09-13 — Dockerfile для бота
## Запрос
Написать Dockerfile.
## Сделано
- `Dockerfile``python:3.12-slim`, non-root user `bot`, `CMD python -m bot.main`
- `.dockerignore` — исключает `.venv`, `.env`, `data/`, кэши
- В README добавлен пример `docker build` / `docker run` с volume для SQLite
## Запуск
```bash
docker build -t please-pay-me-bot .
docker run --rm -e BOT_TOKEN=... -v ./data:/app/data please-pay-me-bot
```
@@ -0,0 +1,21 @@
# 2026-09-13 — SOCKS5-прокси для бота в Docker
## Запрос
Xray на хосте (10808/10809) слушает 127.0.0.1; контейнер его не видит. Нужно открыть listen на 0.0.0.0 и настроить aiogram через SOCKS5.
## Сделано в репозитории
- `aiohttp-socks` в `requirements.txt`
- `PROXY_URL` в `bot/config.py` (опционально)
- `bot/main.py``AiohttpSession(proxy=...)` (официальный API aiogram 3, без `_connector_init`)
- `scripts/open_xray_listen.sh` — правки Xray на Ubuntu-хосте + restart
- `docker-compose.yml``PROXY_URL=socks5://172.17.0.1:10808` по умолчанию
- README: секция прокси + firewall warning
## На хосте (вручную)
```bash
./scripts/open_xray_listen.sh
# в .env: PROXY_URL=socks5://172.17.0.1:10808
docker compose up -d --build
```
Конфиг `/usr/local/etc/xray/config.json` с этой Windows-машины недоступен — правки только через скрипт на Ubuntu.
@@ -0,0 +1,17 @@
# 2026-09-13 — Fix: ProxyTimeout к 172.17.0.1
## Симптом
Контейнер: `Proxy connection timed out` к `socks5://172.17.0.1:10808`.
## Причина
В bridge/Portainer-сети хост `172.17.0.1:10808` часто недоступен (Xray на 127.0.0.1 и/или другая docker-сеть).
## Исправление
- `docker-compose.yml`: `network_mode: host` + `PROXY_URL=socks5://127.0.0.1:10808`
- Убран захардкоженный `BOT_TOKEN` из compose (был в логах/файле — нужен revoke у BotFather)
- Xray можно оставить на `127.0.0.1` (безопаснее, чем 0.0.0.0)
## Действия на сервере
1. BotFather → revoke/перевыпустить токен
2. В `.env`: новый токен + `PROXY_URL=socks5://127.0.0.1:10808`
3. `docker compose up -d --build`
@@ -0,0 +1,13 @@
# 2026-09-13 — Траты за произвольную дату
## Запрос
Возможность добавлять трату за определённое число.
## Сделано
- Парсер: `за 12.09 250 кофе`, `250 кофе за 12.09`, `12.09 250`, `250 кофе 12.09`
- Дата без года для трат берёт прошлое/текущее (не будущее), в отличие от даты бюджета
- `BudgetService.add_expense(..., spent_at=)` с проверкой периода бюджета
- FSM `/spend` и кнопка «📅 За дату»
- `/day 12.09` — список трат за дату
Месяц в дате с точкой лучше писать двузначно (`12.09`), чтобы не путать с суммой `12.5`.
@@ -0,0 +1,11 @@
# 2026-09-13 — Fix: FSM «За дату» глотал все сообщения
## Баг
После «📅 За дату» состояние `waiting_date` было зарегистрировано раньше `/status`, `/today`, `/day`. Любой ввод (включая кнопки) шёл в парсер даты. Сообщение `12.09 кб` целиком не парсилось как дата → вечный цикл ошибки.
## Исправление
- Команды и кнопки меню регистрируются **до** FSM и сбрасывают state
- На шаге даты: one-shot `12.09 250 кб`, либо дата из первого токена (`12.09 кб` → дата + заметка, потом спросить сумму)
- Подсказка про `/cancel`
После деплоя: если бот всё ещё «залип» — один раз `/cancel` или `/help`.
@@ -0,0 +1,11 @@
# 2026-09-13 — Постраничный просмотр трат периода
## Запрос
Просматривать траты за текущий период бюджета постранично.
## Сделано
- Репозиторий: `count/sum/list_expenses_in_period` с LIMIT/OFFSET
- `BudgetService.get_period_expenses_page` (по 8 записей, новые сверху)
- `/history` и кнопка «📒 Период»
- Inline «‹ Назад» / «N/M» / «Вперёд ›»
- Сумма статуса периода тоже считается строго в границах бюджета
@@ -0,0 +1,27 @@
# 2026-09-13 — API + веб-кабинет для бюджетов и трат
## Запрос
Сделать API и фронт для просмотра бюджетов и трат.
## Сделано
### API (`api/`)
- FastAPI, тот же SQLite (`data/budget.db`, WAL)
- `GET /api/health` без токена
- `GET /api/budgets`, `/api/budgets/{user_id}`, `/api/budgets/{user_id}/expenses`
- Auth: `X-API-Token` / Bearer `API_TOKEN`
### Web (`web/`)
- React + Vite + React Router
- Список бюджетов, деталка со статусом и постраничными тратами
- Токен в localStorage
### Docker
- `Dockerfile.api` (multi-stage: npm build + uvicorn)
- `docker-compose.yml`: сервисы `bot` + `web` (:8000)
## Запуск
```bash
# .env: API_TOKEN=...
docker compose up -d --build
# кабинет: http://HOST:8000
```
@@ -0,0 +1,8 @@
# 2026-09-13 — Уточнение docker-compose: BOT_TOKEN vs API_TOKEN
## Вопрос
Почему у bot убран токен и зачем фронту токен бота?
## Ответ
1. **BOT_TOKEN не убирался у бота** — убрали только хардкод из compose (токен светился в логах). Бот читал его из `.env` через `env_file`. Сейчас явно: `BOT_TOKEN: ${BOT_TOKEN}`.
2. **Фронту BOT_TOKEN не нужен.** Нужен отдельный `API_TOKEN` для защиты read-only API кабинета. У сервиса `web` убран `env_file`, чтобы Telegram-токен туда вообще не попадал.
@@ -0,0 +1,10 @@
# 2026-09-13 — Разделение портов фронт/API
## Запрос
Фронт на внешнем порту 51290, API на 51291; фронт ходит на localhost:51291.
## Сделано
- `api` сервис: `51291:8000`, только FastAPI (без статики)
- `web` сервис: nginx `51290:80`, `VITE_API_BASE_URL=http://localhost:51291`
- CORS по умолчанию: `http://localhost:51290`, `http://127.0.0.1:51290`
- `web/src/api.ts` собирает абсолютные URL к API
@@ -0,0 +1,4 @@
# 2026-09-13 — Красивая 404
Стилизована под кабинет (Syne/Manrope, зелёный градиент, motion).
Файлы: корневой `404.html` + `web/public/404.html`, nginx `error_page 404`.
@@ -0,0 +1,4 @@
# 2026-09-13 — Nginx proxy /api → API
Фронт ходит на same-origin `/api`. Nginx проксирует на `http://api:8000` (сервис compose = хост `localhost:51291`).
`VITE_API_BASE_URL` по умолчанию пустой. Vite dev proxy → `http://localhost:51291`.
@@ -0,0 +1,11 @@
# 2026-09-13 — Fix: API readonly database
## Ошибка
`sqlite3.OperationalError: attempt to write a readonly database` на `PRAGMA journal_mode=WAL`.
## Причина
API (uid 1001) и бот (uid 1000) делили `./data`; WAL/схема требуют запись.
## Исправление
- `Database(read_only=True)` для API: `file:...?mode=ro`, без WAL и SCHEMA
- `Dockerfile.api` — uid 1000 как у бота
@@ -0,0 +1,11 @@
# 2026-09-13 — Fix: JSON.parse на фронте
## Симптом
`JSON.parse: unexpected character at line 1 column 1` — в ответ на `/api/budgets` приходил HTML `index.html` (SPA fallback).
## Причина
В `web/nginx.conf` не было `location /api/``try_files` отдавал фронт.
## Исправление
Вернул `proxy_pass http://api:8000` для `/api/`, улучшил разбор ошибок во фронте.
Пересобрать: `docker compose up -d --build web`
@@ -0,0 +1,10 @@
# 2026-09-13 — Личный кабинет через Telegram Login
## Сделано
- POST `/api/auth/telegram` — проверка Login Widget + JWT
- GET `/api/me`, `/api/me/budget`, `/api/me/expenses` — только свой user_id
- Фронт: кнопка Telegram Login, кабинет своего бюджета
- Env: `BOT_TOKEN`, `TELEGRAM_BOT_USERNAME` (+ опционально `API_TOKEN` для админских `/api/budgets`)
## BotFather
`/setdomain` на домен кабинета (нужен публичный HTTPS).
@@ -0,0 +1,13 @@
# 2026-09-13 — Write API + формы трат/бюджета в кабинете
## Сделано
- API: `read_only=False`
- `POST /api/me/expenses`, `DELETE /api/me/expenses/last`, `PUT /api/me/budget`
- Фронт: формы «Добавить трату» и «Задать/обновить бюджет»
- CORS: PUT/DELETE
## На сервере
```bash
sudo chown -R 1000:1000 ./data
docker compose up -d --build api web
```
@@ -0,0 +1,9 @@
# 2026-09-13 — Редизайн web-кабинета
## UX
- Login: brand-first, один CTA (Telegram)
- Кабинет: hero = остаток; быстрая трата; история; настройки бюджета в `<details>`
- Flash с aria-live, skeleton, focus-visible, reduced-motion
- Крупные touch-target (48px), sticky topbar
Сохранить палитру леса/зелени проекта. Пересборка: `docker compose up -d --build web`
@@ -0,0 +1,4 @@
# 2026-09-13 — Строгий системный UI кабинета
Светлая тема, IBM Plex Sans/Mono, шкала отступов 4–48px, hairline-границы,
без glow/градиентов. Суммы в mono. Секции: Обзор → Операция → Журнал → Параметры.
@@ -0,0 +1,26 @@
# 2026-09-13 — Системный продуктовый дизайн (web)
Заложена расширяемая дизайн-система кабинета вместо монолита `App.tsx`.
## Структура
- `web/src/design/` — токены (`tokens.css`), base, стили shell/UI
- `web/src/components/ui/` — Button, Field, Flash, ProgressBar, MetricGrid, PageHeader, Section, EmptyState, Banner
- `web/src/components/layout/` — AppShell, AuthLayout, `PRIMARY_NAV` (точка расширения меню)
- `web/src/auth/` — AuthProvider, RequireAuth
- `web/src/cabinet/` — CabinetProvider (бюджет/траты), CabinetLayout
- `web/src/pages/` — Login, Overview, Operations, Journal, Period (+ ComingSoon заготовка)
## Маршруты
`/login` · `/` · `/operations` · `/journal` · `/period`
В nav зарезервированы «Отчёты» и «Настройки» (`soon: true`).
## Как добавлять фичу
1. Пункт в `PRIMARY_NAV`
2. Route под `CabinetLayout` в `App.tsx`
3. Страница в `pages/`, UI из `components/ui`
4. Доменное состояние — в `cabinet/` или новый context
Сборка `npm run build` проходит успешно.
@@ -0,0 +1,18 @@
# 2026-09-13 — Бот ходит в БД только через API
## Цель
Единый путь записи/чтения: web и Telegram-бот → FastAPI → SQLite.
Бот больше не монтирует `./data` и не открывает `budget.db`.
## API
- `POST /api/auth/internal` + `X-API-Token` / `API_TOKEN` → JWT как у web
- Бот вызывает те же `/api/me/budget`, `/api/me/expenses`, undo, upsert
- `GET /api/me/expenses?spent_at=YYYY-MM-DD` — траты за день (для /today, /day)
## Bot
- `bot/clients/budget_api.py``BudgetApiClient` (aiohttp)
- `bot/config.py`: `API_BASE_URL`, `API_TOKEN` (обязателен)
- Compose: bot без volume data; `API_BASE_URL=http://127.0.0.1:51291` при host network
## Деплой
Пересобрать `api` + `bot`, убедиться что `API_TOKEN` одинаковый в обоих сервисах.
@@ -0,0 +1,23 @@
# 2026-09-13 — Несколько бюджетов + is_active
## Модель
- У пользователя много строк в `budgets` (снят UNIQUE user_id)
- Поля: `name`, `is_active`, `users.selected_budget_id`
- `expenses.budget_id` — траты привязаны к конкретному бюджету
- Миграция на старте API (`migrate_schema`) для старых БД
## Семантика
- **Активный** — можно писать траты
- **Текущий (selected)** — default для /status, операций, журнала
- Неактивный отклоняет новые expenses
## API
- `GET/POST /api/me/budgets`
- `PUT /api/me/budgets/{id}`
- `PATCH /api/me/budgets/{id}/active`
- `POST /api/me/budgets/{id}/select`
- `budget_id` на expenses / budget GET
## Клиенты
- Web: страница `/budgets`, переключатель вкл/выкл и «текущий»
- Bot: `/budgets`, `/budget` создаёт новый; inline select/toggle
@@ -0,0 +1,18 @@
# 2026-09-13 — API на C# + PostgreSQL
## Что сделано
- Новый стек: `src/PleasePayMe.*` (Domain / Application / Infrastructure / Api)
- EF Core + Npgsql, миграция `InitialCreate`, авто-`Migrate()` на старте
- HTTP-контракт прежний (`/api/auth/*`, `/api/me/*`, `/api/budgets/*`, snake_case JSON, `{detail}`)
- Docker: сервис `db` (postgres:16), `Dockerfile.api` → ASP.NET
- Python `api/` помечен deprecated; бот и web без смены контракта
## Запуск
```bash
docker compose up -d --build
```
Переменные: `BOT_TOKEN`, `API_TOKEN`, `POSTGRES_*`.
## Данные
SQLite `data/budget.db` в compose больше не используется.
Перенос старых данных — отдельной задачей (не автоматический).
@@ -0,0 +1,5 @@
# 2026-09-13 — Удаление бюджетов
- `DELETE /api/me/budgets/{id}` → 204; траты каскадом; selected переназначается
- Web: кнопка «Удалить» с confirm на странице Бюджеты
- Bot: кнопка 🗑 в `/budgets`
@@ -0,0 +1,14 @@
# 2026-09-13 — Раздел «Работа»
## Модель
- Таблица `jobs`: name, salary_amount, pay_days (integer[] 131), is_active
- Если дня нет в месяце (напр. 31) — берётся последний день месяца
- API считает `next_pay_dates` (ближайшие выплаты)
## API
- `GET/POST /api/me/jobs`
- `PUT/DELETE /api/me/jobs/{id}`
## Web
- Навигация «Работа» → `/work`
- Список + форма: название, ЗП, дни (текст `5, 20` + быстрые чипы)
@@ -0,0 +1,6 @@
# 2026-09-13 — Работа: доля выплат + сдвиг с выходных
- Максимум 2 дня в `pay_days`
- `first_pay_percent` — доля более раннего дня; второй = 100 − first
- `weekend_policy`: `before_weekend` | `after_weekend` (сб/вс → пт или пн)
- `next_pays[]`: дата (уже с учётом сдвига), scheduled_day, percent, amount
@@ -0,0 +1,5 @@
# 2026-09-13 — Выбор даты начала бюджета
- API уже принимал `start_date` (create/update/upsert); upsert теперь пробрасывает дату.
- Web: поле «С даты» в Бюджеты и Период; по умолчанию `todayIso()` при создании.
- Bot client: `create_budget` / `set_budget` передают `start_date`, если задан (иначе API = сегодня).
@@ -0,0 +1,6 @@
# 2026-09-13 — Календарь бюджетов и выплат
- Маршрут `/calendar`, пункт «Календарь» в nav
- Полоски цвета = периоды бюджетов (стабильный цвет по id)
- Кружки = дни ЗП (с учётом weekend_policy, как в PaySchedule)
- Клик по дню — детали; легенда ниже сетки
@@ -0,0 +1,5 @@
# 2026-09-13 — Календарь 2.0: траты на днях
- API `GET /api/me/expenses/range?from=&to=` — траты по всем бюджетам пользователя (≤93 дня)
- Ячейка: точка + компактная сумма трат за день
- Клик по дню → блок «Операции» (сумма, заметка, бюджет) + контекст (бюджеты/ЗП)
@@ -0,0 +1,6 @@
# 2026-09-19 — Траты без активного бюджета + журнал «все»
- `AddExpense`: больше не требует `is_active`; для неактивного бюджета даты периода не ограничивают
- `GET /api/me/expenses?all=true` — пагинация по всем бюджетам пользователя (`budget_id: null`)
- Журнал: переключатель **Все / Текущий** (по умолчанию все); в режиме «все» видно имя бюджета
- Операции: выбор бюджета в форме, в т.ч. выключенного
@@ -0,0 +1,18 @@
# 2026-09-19 — Flutter mobile + portable Widgetbook
## Mobile app (`mobile/`)
- Flutter scaffold (android/ios/web/windows)
- Theme tokens aligned with web `tokens.css` + light/dark `ThemeData`
- UI kit: atoms / molecules / navigation / feedback / sample screens
- IBM Plex Sans via `google_fonts`
## Widgetbook (`mobile/widgetbook/`)
- Separate package (`please_pay_me_widgetbook`) path-depends on app
- Catalog: Atoms, Molecules, Navigation, Feedback, Screens + knobs
- Addons: MaterialTheme (light/dark), Localization (ru/en), Viewport (iPhone/Android)
- Smoke tests: `flutter test` — all use-cases registered
## Run
```bash
cd mobile/widgetbook && flutter pub get && flutter run -d chrome
```
@@ -0,0 +1,5 @@
# 2026-09-19 — PS1 скрипты запуска mobile/widgetbook
- `mobile/run.ps1` — app или `-Target widgetbook`
- `mobile/widgetbook/run.ps1` — каталог UI (`-Device chrome|windows|edge`)
- Ищут Flutter в PATH и `%USERPROFILE%\flutter\bin`
@@ -0,0 +1,5 @@
# 2026-09-19 — Widgetbook: добавлен Windows desktop support
- `flutter create . --platforms=windows,web,android,ios` в `mobile/widgetbook`
- Исправлена заглушка `test/widget_test.dart` после create
- Повтор: `.\run.ps1 -Device windows`
@@ -0,0 +1,66 @@
# 2026-09-19 — Мобильный UI-кит переведён на iOS (Cupertino)
## Запрос
Widgetbook запустился, но Material-компоненты визуально не устроили. Задача — сделать
компоненты «из iOS kit».
## Что сделано
### Токены и тема (`mobile/lib/theme/`)
- `tokens.dart` переписан под Apple HIG: семантические цвета (`label`, `secondaryLabel`,
`separator`, `opaqueSeparator`, `groupedBackground`, `groupedSurface`, `barBackground`)
и системная палитра (`systemRed/Orange/Green/Gray/Gray3/Gray5/Gray6`) объявлены как
`CupertinoDynamicColor.withBrightness` — light/dark резолвится самим фреймворком через
`AppColors.of(context, color)`.
- Бренд-тинт остался зелёным (`accent` = #12885A / #3CD68C в тёмной) и подменяет systemBlue.
- Типографика — шкала SF Pro: largeTitle 34 … caption2 11 с нативными letterSpacing.
- Размеры контролов: 50 / 44 / 34 pt, hairline 0.5, радиусы 6/10/12/16/capsule.
- `app_theme.dart` теперь возвращает `CupertinoThemeData` (`buildLightTheme` /
`buildDarkTheme`), шрифт — Inter через google_fonts как кросс-платформенная замена SF Pro
(на Windows/web SF недоступен).
### Компоненты (`mobile/lib/ui/`)
Material-версии удалены, вместо них Cupertino:
- atoms: `AppButton` (filled/tinted/gray/plain/destructive × large/medium/small),
`AppTextField` (CupertinoTextField + error/prefix/clear), `AppText` (именованные
конструкторы по шкале iOS) + `AppSectionHeader`, `AppIcon` + `AppIconBadge`
(Settings-style скруглённый квадрат).
- molecules: `AppListSection` (inset-grouped, header/footer, hairline с indent),
`AppListTile` (leading/value/chevron, press-highlight, destructive), `AppSwitchRow`
(CupertinoSwitch), `AppCard`, `AppChip` (капсула), `AppAvatar`.
- navigation: `AppNavBar` (реализует `ObstructingPreferredSizeWidget`, есть subtitle),
`AppLargeNavBar` (`CupertinoSliverNavigationBar`), `AppTabBar` (наследник
`CupertinoTabBar`, чтобы отдавать в `CupertinoTabScaffold`), `AppSegmentedControl`
(`CupertinoSlidingSegmentedControl`), `AppAlert` + `showAppAlert` + `showAppActionSheet`.
- feedback: `AppSpinner` (`CupertinoActivityIndicator`), `AppProgressBar` (4pt капсула),
`AppSkeleton` / `AppSkeletonRow`, `AppToast` + `showAppToast` (blur-капсула через
OverlayEntry — в iOS нет SnackBar).
- screens: Feed (large title + сегменты + группы + empty/loading), Detail (hero-сумма,
прогресс, группа параметров, кнопки), Profile (Settings-подобный экран со свитчами).
- `main.dart` переведён на `CupertinoApp` + `CupertinoTabScaffold`.
### Widgetbook (`mobile/widgetbook/`)
- `Widgetbook.cupertino` + `CupertinoThemeAddon` вместо material-варианта.
- Новый `lib/support/preview.dart``IosPreview` кладёт use-case на grouped-фон,
чтобы карточки и разделители читались как в приложении.
- Каталог: Atoms (Button/TextField/Typography/Icons), Molecules (Card/ListTile/SwitchRow/
Chip/Avatar), Navigation (NavBar/TabBar/SegmentedControl/Alert), Feedback (Progress/
Skeleton/Toast), Screens (Feed/Detail/Profile) — 42 use-case.
- Knobs: добавлены `knobEnum` и `knobTint` (дропдаун по системным тинтам), убран
deprecated `knobs.list`.
### Тесты
`widgetbook/test/catalog_smoke_test.dart` больше не проверяет только структуру: каждый
use-case пампится в light и dark внутри `WidgetbookScope` (knobs читают `WidgetbookState`
из контекста, иначе падает «No Widgetbook found in the context»). В тестах отключён
`GoogleFonts.config.allowRuntimeFetching` и вызван `TestWidgetsFlutterBinding.ensureInitialized()`.
## Результат
- `flutter analyze` (mobile + widgetbook) — 0 issues.
- `flutter test` — 84 теста в widgetbook + 1 в приложении, всё зелёное.
## Запуск
```powershell
cd mobile\widgetbook
.\run.ps1 -Device windows
```
@@ -0,0 +1,72 @@
# 2026-09-20 — Мобильное приложение Please Pay Me собрано на iOS-ките
## Запрос
«Собери теперь приложение please-pay-me, используя эти компоненты» — то есть поверх
Cupertino-кита из прошлой сессии сделать рабочее приложение против существующего API.
## Что сделано
### Слой данных (`lib/data/`)
- `models/``Budget`, `BudgetStatus` (+ вычисляемые `spentProgress` / `dailyProgress`),
`Expense`, `ExpensesPage`, `Job`, `UpcomingPay`, `WeekendPolicy`, `AuthUser`, `AuthSession`.
Зеркалят `web/src/types.ts`, парсятся через `models/json.dart` — толерантные
коэрсеры (`int`/`double`/`string`), потому что API отдаёт числа из C#-рекордов.
- `api/api_client.dart` — транспорт на `http`: Bearer-токен через `tokenProvider`,
маппинг `detail`/`title` в `ApiException`, отдельная обработка 401 с колбэком
`onUnauthorized` (разлогинивает сессию).
- `repositories/` — интерфейсы `BudgetRepository` / `ExpenseRepository` /
`JobRepository` / `UserRepository` + REST-реализации на все ручки `MeController`.
- `demo/demo_backend.dart` — in-memory бэкенд, повторяющий математику конверта
(`dailyLimit = remaining / daysLeft`). Используется в демо-режиме, превью и тестах;
`DemoBackend.empty()` — для пустых состояний.
### Ядро (`lib/core/`)
- `config/app_config.dart``PPM_API_BASE_URL`, `PPM_WEB_URL`, `PPM_DEMO` через
`--dart-define`; пустой адрес API автоматически включает демо-режим.
- `storage/session_storage.dart` — интерфейс + `SharedPreferences` и in-memory реализации.
- `state/async_value.dart` — sealed `AsyncLoading/AsyncData/AsyncError` с `map(...)`,
чтобы экраны матчились по состоянию, а не жонглировали тремя nullable-полями.
- `format/formatters.dart` — деньги (`1 234,50 ₽`), русские плюрали, «Сегодня/Вчера».
### Состояние (`lib/features/*/`)
- `SessionController` — владеет авторизацией и выдаёт репозитории (REST или demo),
так что остальное приложение не знает про токены. `sessionKey` пересобирает
фичевые контроллеры при входе/выходе.
- `BudgetsController` — источник правды по конвертам (список, выбранный, мутации
с авто-перезагрузкой, возврат текста ошибки вместо исключения).
- `JournalController` — пагинация + группировка по дням + скоуп «текущий/все».
- `JobsController` — работы и ближайшая выплата.
### Экраны (`lib/features/`, `lib/app/`)
Пять вкладок: Обзор, Журнал, Бюджеты, Работа, Профиль (+ экран входа и сплэш).
Формы — модальные шиты: новая/редактирование траты, бюджета, работы; все они
принимают `onSubmit`, возвращающий текст ошибки, и не знают про репозитории.
`_SessionGate` разводит `restoring / signedOut / signedIn`.
### Кит
Удалены демо-экраны `lib/ui/screens/*`; добавлены `AppEmptyState`, `AppErrorView`,
`AppLoadingView`, `showAppDatePicker`, `showAppFormSheet`.
### Widgetbook
Папка Screens теперь показывает **настоящие экраны** приложения на `DemoScope`
(demo-бэкенд + провайдеры), включая пустые состояния, формы и полный таб-бар.
### Авторизация — известное ограничение
Telegram Login Widget работает только в вебе. Мобильный клиент принимает JWT из
`localStorage.ppm_session_jwt` веб-кабинета и валидирует его через `GET /api/me`.
Нативный вход (WebView с Telegram-виджетом либо one-time code через бота)
не делался — это отдельное продуктовое решение.
## Тесты
- `mobile`: 32 теста — парсинг моделей, форматтеры, все контроллеры на demo-бэкенде,
виджет-сценарии (вход, табы, пустые состояния, запись траты end-to-end).
- `mobile/widgetbook`: 100 — каждый use-case рендерится в light и dark.
- `flutter analyze` по обоим пакетам — 0 issues.
- `flutter build windows --debug` проходит (проверка плагинов shared_preferences/url_launcher).
## Запуск
```powershell
cd mobile
.\run.ps1 # демо-данные
.\run.ps1 -ApiBaseUrl https://ppm.example.com
```
@@ -0,0 +1,69 @@
# 2026-09-20 — Вход через WebView + Telegram Login Widget
## Запрос
Реализовать нативный вход: открыть кабинет в WebView и забрать JWT через
JS-канал. Бэкенд не менять.
## Почему не «просто открыть страницу»
Telegram Login Widget в обычном браузере работает через iframe + `data-onauth`.
В Android/iOS WebView этот путь ломается:
- popup (`window.open` на `oauth.telegram.org`) не возвращает `opener`;
- iframe режется third-party cookies и User-Agent с маркером `; wv`;
- callback `onTelegramAuth` просто не вызывается.
Поэтому в WebView виджет переключается на **redirect-режим** (`data-auth-url`),
а Flutter забирает уже готовый JWT, который кабинет и так кладёт в
`localStorage.ppm_session_jwt`. API (`POST /api/auth/telegram`) не трогали.
## Поток
1. Экран входа на iOS/Android показывает «Войти через Telegram».
2. Открывается fullscreen WebView на `{cabinet}/login`.
3. Кабинет видит канал `window.PpmAuth` → ставит виджету `data-auth-url=/login`.
4. Пользователь логинится в Telegram; редирект приходит на `/login?id&hash&…`.
5. `telegramPayloadFromQuery` собирает payload, кабинет зовёт тот же
`loginWithTelegram` / `POST /api/auth/telegram`.
6. `setSession` пишет JWT в `localStorage` и зовёт `PpmAuth.postMessage`.
7. Flutter парсит сообщение, закрывает WebView, проверяет токен через
`GET /api/me`, кладёт сессию в `SharedPreferences`.
Запасные пути, если кабинет ещё не задеплоен с мостом:
- JS в WebView хукает `localStorage.setItem('ppm_session_jwt')` и поллит ключ;
- на Windows/web WebView нет — форма «вставить токен»;
- «Демо-режим» по-прежнему без сети.
## Что изменилось
### Web (не API)
- `web/src/auth/telegramRedirect.ts` — разбор query и `postTokenToNativeApp`.
- `TelegramLoginButton``data-auth-url` только если есть `window.PpmAuth`.
- `LoginPage` — автологин, если в URL уже лежит telegram-payload.
- `setSession` — после записи токена шлёт его в канал.
### Mobile
- `TelegramLoginScreen` (`webview_flutter`): канал `PpmAuth`, Chrome UA без
`; wv`, third-party cookies на Android, `tg://` уходит во внешнее приложение,
кнопка «назад» после oauth.telegram.org.
- `resolveCabinetLoginUri` всегда открывает `/login`.
- `INTERNET` в main `AndroidManifest` (раньше был только в debug).
- iOS: `LSApplicationQueriesSchemes` для `tg` / `telegram`.
- `PPM_WEB_URL` по умолчанию = `PPM_API_BASE_URL` (один origin за nginx).
## Тесты
- `resolveApiBaseUrl` / `resolveCabinetLoginUri` / парсер канала.
- виджет: инжектированный launcher отдаёт JWT → `GET /api/me` → signed in.
- 32 теста приложения, 102 в Widgetbook, `flutter analyze` чистый.
## Запуск против живого кабинета
```powershell
cd mobile
.\run.ps1 -Device windows # на Windows будет форма токена
# на телефоне:
.\run.ps1 -ApiBaseUrl https://<домен-кабинета>
```
Нужен публичный HTTPS и BotFather `/setdomain` на этот домен — как для веба.
@@ -0,0 +1,22 @@
# 2026-09-20 — API URL мобильного приложения из `.env`
## Запрос
Задавать `ApiBaseUrl` через переменные `.env`, а не флагом `run.ps1`.
## Решение
Конфиг читается из `mobile/.env` и уходит во Flutter как
`--dart-define-from-file` — без `flutter_dotenv` в ассетах (иначе gitignored
`.env` ломал бы CI: asset обязан существовать в момент сборки).
Порядок в `AppConfig.fromEnvironment`:
1. `--dart-define` (в т.ч. из файла)
2. явно переданная map (тесты)
3. пустой URL → демо-режим
`run.ps1`: если `.env` нет — копирует `.env.example`. `-ApiBaseUrl` / `-WebUrl`
остались как разовый override поверх файла. Скрипт сохранён в UTF-8 с BOM и
без вложенных кавычек — иначе PowerShell 5.1 на Windows-1251 сыпется
`TerminatorExpectedAtEndOfString`.
Ключи: `PPM_API_BASE_URL`, `PPM_WEB_URL`, `PPM_DEMO`.
Локальный `.env` в gitignore (есть и в корневом).
@@ -0,0 +1,37 @@
# 2026-09-20 — Вход через Яндекс ID (web + mobile)
## Запрос
Кнопка «Войти через Яндекс» в кабинете и в приложении. Client ID / secret
уже заведены в Yandex OAuth.
## Решение
Authorization code. Secret только на API (`YANDEX_CLIENT_SECRET` в `.env`,
не в web/mobile и не в git). Клиенты получают `client_id` с
`GET /api/auth/providers` и шлют `code` + `redirect_uri` в
`POST /api/auth/yandex`.
API меняет код на токен Яндекса (`oauth.yandex.ru/token`), читает профиль
(`login.yandex.ru/info`) и выдаёт тот же JWT, что и Telegram.
`users.user_id` для Яндекса: ` (1 << 50) | yandexId ` — не пересекается с
Telegram id и остаётся внутри `Number.MAX_SAFE_INTEGER`, чтобы JSON в
браузере и Flutter не терял точность.
`redirect_uri` сверяется с allowlist (продакшен `https://please-pay-me.ru/`,
localhost:51290 и :5173, плюс `YANDEX_REDIRECT_URI` / `YANDEX_REDIRECT_URIS`).
## Потоки
- Web: кнопка → `oauth.yandex.ru/authorize` → возврат на `/?code=`
(как в кабинете Яндекса) → `RequireAuth` переносит query на `/login`
`loginWithYandex``setSession`.
- Mobile: своя кнопка → WebView на Яндекс → перехват `{кабинет}/?code=`
`POST /api/auth/yandex` из Flutter.
## Что нужно в консоли Яндекса
Redirect URI: `https://please-pay-me.ru/` — как зарегистрировано в Яндексе.
## Секрет
Значения записаны в корневой `.env` (gitignore). В репозиторий не коммитятся.
Секрет светился в чате — если репозиторий общий, лучше перевыпустить в
кабинете Яндекса.
@@ -0,0 +1,8 @@
# 2026-09-20 — Страница 502
По аналогии с `404.html`: тот же тёмный градиент, Syne/Manrope, motion.
Текст про недоступный шлюз, кнопки «Обновить» и «На главную».
Файлы: корневой `502.html` + `web/public/502.html`.
nginx: `error_page 502 503 504 /502.html` — 503/504 на ту же страницу,
потому что при мёртвом API приходит не только 502.
@@ -0,0 +1,8 @@
# 2026-09-20 — Yandex redirect_uri = корень сайта
В кабинете Яндекса Callback URL: `https://please-pay-me.ru/` (не `/login`).
Клиенты слали `/login` → Яндекс отвечал «redirect_uri не совпадает».
Теперь authorize и обмен кода используют `https://please-pay-me.ru/`.
Возврат `/?code=` RequireAuth переносит на `/login?code=`, чтобы SPA
не выкинула query. Mobile перехватывает тот же корень.
@@ -0,0 +1,22 @@
# 2026-09-20 — Web UI-кит как в mobile iOS kit
Кабинет переведён на те же токены и примитивы, что Flutter `mobile/lib/ui`.
## Токены
`web/src/design/tokens.css` — Apple HIG: accent #12885A / #3CD68C, label /
separator / grouped surfaces, SF-шкала, 4pt сетка, радиусы 6/10/12/16.
Светлая и тёмная тема через `prefers-color-scheme`. Шрифт Inter.
Старые `--color-*` оставлены как алиасы.
## Кит
`web/src/ui/` — AppButton (filled/tinted/gray/plain/destructive), AppText,
AppTextField, AppListSection/Tile, AppSegmented, AppProgress, AppEmpty/Error,
Chip, Avatar, Skeleton. Стили в `kit.css`.
Старые `components/ui` (Button, Field, PageHeader, MetricGrid, …) — тонкие
обёртки над китом, страницы не ломаются.
## Оболочка
AppShell: translucent bar, inset-grouped нав на десктопе, капсулы на узком
экране. Обзор и журнал собраны на list/segmented. Остальные экраны получают
iOS-вид через те же кнопки, поля и `data-list`.
@@ -0,0 +1,5 @@
# 2026-09-20 — Адрес кабинета только из `.env`
С экрана входа убрано поле «Адрес кабинета». Origin берётся из
`PPM_WEB_URL` / `PPM_API_BASE_URL` (`https://please-pay-me.ru/`).
Поле токена осталось только в запасном режиме.
@@ -0,0 +1,11 @@
# 2026-09-20 — Почему «Яндекс недоступен»
Тост шёл из `_yandex == null`: `GET /api/auth/providers` не вызывался,
потому что `AppConfig.fromEnvironment()` читает только `--dart-define`.
Запуск из IDE без `run.ps1` оставлял URL пустым, ошибку глотали.
На проде `/api/auth/providers` уже отдаёт `enabled: true`.
Фикс: `main()` читает `mobile/.env` с диска (dart-define по-прежнему
главнее). По тапу провайдеры перезапрашиваются, в тосте — реальная
причина. На Windows WebView нет — отдельное сообщение.
@@ -0,0 +1,4 @@
# 2026-09-20 — Дефолт кабинета
Если `PPM_API_BASE_URL` / `PPM_WEB_URL` не заданы, `AppConfig` берёт
`https://please-pay-me.ru`. Демо только при `PPM_DEMO=true`.
@@ -0,0 +1,6 @@
# 2026-09-20 — Переключатель темы в профиле
На экране профиля: Системная / Светлая / Тёмная (`AppSegmentedControl`).
`ThemeController` хранит выбор в SharedPreferences (`ppm_theme_preference`),
отдельно от JWT — тема переживает logout. `PleasePayMeApp` резолвит
brightness и прокидывает его в `CupertinoTheme` + `MediaQuery.platformBrightness`.
@@ -0,0 +1,5 @@
# 2026-09-20 — Android nav bar в тёмной теме
Системная нижняя панель: прозрачный чёрный (`#00000000`), без contrast
scrim (`enforceNavigationBarContrast=false`). Иконки светлые. Edge-to-edge
включается в `main`, стиль окна — в `values-night`.
@@ -0,0 +1,6 @@
# 2026-09-20 — Сплэш Please Pay Me
Фирменный экран запуска: зелёный знак ₽, название, слоган, спиннер.
Нативный Android (вкл. 12+ splash API) и iOS launch — тот же фон
`#F2F2F7` / `#000000`, чтобы не было вспышки. `main` рисует сплэш сразу
и держит его минимум 850 мс, пока восстанавливается сессия.
@@ -0,0 +1,7 @@
# 2026-09-20 — Правовые документы
Публичные страницы `/legal/offer|privacy|consent|cookies`.
Футер на логине и в кабинете. Cookie-баннер при первом заходе
(технические / все, отказ от Метрики). Чекбоксы оферты и согласия
на входе в web и mobile; в профиле — «Правовая информация» со
ссылками на веб-версии. Реквизиты ИП Архангельский В.А.
@@ -0,0 +1,5 @@
# 2026-09-20 — Название и иконка
Отображаемое имя: **Дожить до ЗП**. Иконка: зелёный квадрат #12885A и белый ₽.
Проставлена в Android (adaptive), iOS, Windows, favicon кабинета.
`AppBrand.name` / `APP_NAME` — единый источник в UI.
@@ -0,0 +1,4 @@
# 2026-09-20 — Иконка для RuStore
`mobile/assets/branding/rustore_icon_512.png`: 512×512, 1:1, ~145 КБ.
Уменьшена из `app_icon.png` (1024).
@@ -0,0 +1,6 @@
# 2026-09-20 — Скачать APK с сайта
Ссылка `/dozhit-do-zp.apk` на логине и в футере.
Файл: `web/public/dozhit-do-zp.apk` (gitignore, 53 МБ).
Перед деплоем веб скопировать свежий
`mobile/build/app/outputs/flutter-apk/app-release.apk`.
@@ -0,0 +1,7 @@
# 2026-09-20 Telegram → Yandex identity link
Вход везде только через Яндекс. Бот больше не выдаёт JWT по сырому Telegram `user_id`: пока аккаунт не связан с Яндексом, `POST /api/auth/internal` отвечает 403 `yandex_required` и ссылкой `https://please-pay-me.ru/?tg_link={token}`.
После входа через Яндекс веб вызывает `POST /api/auth/telegram-link/complete`. API пишет `telegram_yandex_links` и переносит бюджеты / траты / работы с Telegram-id на Yandex-namespaced id (`1<<50 | yandexId`). Дальше бот, кабинет и приложение работают с одним `user_id`.
`POST /api/auth/telegram` отключён (403). Старые Telegram-сессии в кабинете сбрасываются.