feat(app): offline-кэш GET-запросов на дашборде — фаза 5

CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift
(sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт Cached<T>, экран показывает баннер «данные на …».
Контракт для остальных экранов — docs/ai/offline-cache.md.

flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для
just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
This commit is contained in:
Dmitry
2026-09-19 12:40:08 +03:00
parent 95cd6f176e
commit ffc5ed959a
21 changed files with 15852 additions and 60 deletions
+2
View File
@@ -35,3 +35,5 @@ worker (APScheduler, advisory locks) ▼
- [plan.md](plan.md) — фазы и чек-листы проверки.
- [ops.md](ops.md) — деплой на VPS, бэкапы, секреты.
- [links.md](links.md) — внешние API и референсы.
- [offline-cache.md](offline-cache.md) — офлайн-кэш Flutter-клиента: контракт `Cached<T>`,
`CacheInterceptor`, баннер «данные на …» (фаза 5).
+91
View File
@@ -0,0 +1,91 @@
# Offline-кэш (фаза 5)
Контракт, по которому каждый экран Flutter-клиента получает поддержку офлайна. Реализован
целиком на `core/cache/*` + один Dio-интерцептор — экрану достаточно поменять тип провайдера
и один раз показать баннер, ничего не меняя в том, как он делает запросы.
## Идея
Кэш живёт не в провайдерах и не в экранах, а в `Dio` — на уровне HTTP-транспорта, ниже
сгенерированного `fintracker_api`-клиента:
- **`CacheInterceptor`** (`core/cache/cache_interceptor.dart`) добавлен в `apiProvider`
(`core/api/api_client.dart`) последним, после `DateQueryInterceptor` и `_AuthInterceptor`.
- `onResponse`: успешный `GET` (статус 200) пишется в кэш по ключу `путь?отсортированные_параметры`
(`CacheInterceptor.requestKey`) — fire-and-forget, ответ не ждёт записи.
- `onError`: если запрос — `GET` и ошибка сетевая (`connectionError`, `connectionTimeout`,
`receiveTimeout`, `sendTimeout`), интерцептор ищет последний закэшированный ответ по тому же
ключу и, если он есть, подменяет ошибку успешным `Response` с `extra['fetchedAt']`
временем, когда этот ответ был реально получен от сервера. Если в кэше пусто — ошибка идёт
дальше как обычно.
- Мутации (не-`GET`) никогда не кэшируются и никогда не подменяются: упавший `POST/PATCH/DELETE`
обязан остаться ошибкой.
- Реальная ошибка сервера (4xx/5xx, `DioExceptionType.badResponse`) кэшем не перекрывается —
подменяется только сетевая недоступность.
- **`ResponseCacheDatabase`** (`core/cache/response_cache.dart`) — таблица `drift` с одной строкой
на ключ (`requestKey`, `body` как JSON-строка, `fetchedAt`). `driftDatabase()` из
`package:drift_flutter` сам выбирает бэкенд: нативный sqlite (FFI) на Android/iOS/desktop,
wasm+OPFS (с падением на IndexedDB) в браузере.
- Сгенерированный API-клиент (`app/packages/api_client`) копирует `Response.extra` в возвращаемый
`Response<T>` без изменений (проверено в `networth_api.dart` и остальных) — значит,
`extra['fetchedAt']` долетает до провайдера без единой правки в генерируемом коде.
## Контракт для экрана
1. **Провайдер оборачивает данные в `Cached<T>`** (`core/cache/cached.dart`) вместо голого `T`:
```dart
final xProvider = FutureProvider.autoDispose<Cached<X>>((ref) async {
final r = await ref.watch(apiProvider).getXApi().x();
return r.cached; // extension: Cached(r.data as T, fetchedAt: r.extra['fetchedAt'])
});
```
Для эндпоинтов, которые сами обрабатывают `null`/404 (см. `metricsStatusProvider`,
`portfolioSummaryHomeProvider` в `features/home/providers.dart`), оборачивайте вручную:
`Cached(value, fetchedAt: r.extra['fetchedAt'] as DateTime?)`.
2. **Экран разворачивает `.data`** там, где раньше было голое значение
(`data: (cached) => Widget(x: cached.data)`), и считает баннер один раз на весь экран:
```dart
final stale = oldestFetch([a.valueOrNull?.fetchedAt, b.valueOrNull?.fetchedAt, ...]);
// ...
if (stale != null) StaleBanner(fetchedAt: stale),
```
`oldestFetch` берёт самое старое время среди тех провайдеров, что реально пришли из кэша
(`fetchedAt != null`); один баннер на экран — не один на плитку, иначе на дашборде из 8
виджетов это станет шумом. `StaleBanner` (`core/widgets/stale_banner.dart`) — тонкий
контейнер в цветах темы («Нет соединения — данные на 12.09.2026 10:31»), без логики.
3. **В тестах экрана — оверрайдить КАЖДЫЙ провайдер**, который экран смотрит, значением
`Cached(...)`. Если забыть один (как было с `portfolioSummaryHomeProvider` до этой фазы),
тест провалится не сразу, а тихо создаст настоящий `apiProvider` → настоящий
`ResponseCacheDatabase` — drift выведет предупреждение "multiple databases" и тест будет
трогать реальный sqlite-файл вместо теста в изоляции. Смотрите `test/home_page_test.dart`
и `_overrides()` в нём как образец.
Эталонная реализация — весь `features/home/` (провайдеры + `HomePage`). Остальные ~24 экрана
на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от
друга (можно параллельно, в отдельных worktree).
## Веб: sqlite3.wasm + drift_worker.js
Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как
`openapi/openapi.json` и Dart-клиент). Пересобирать `just app-web-assets`, когда обновляются
версии пакетов `drift`/`sqlite3` — recipe качает `sqlite3.wasm` с GitHub-релиза пакета
`sqlite3.dart`, версия релиза берётся из `flutter pub deps --json`, и компилирует
`drift_worker.js` из исходника `package:drift/web/drift_worker.dart` через `dart compile js`.
`just build-web` вызывает это автоматически.
## Что не проверено
`flutter build linux --debug` на сборке нативного sqlite3 не дошёл до проверки — упал раньше,
на несвязанной с кэшем проблеме `flutter_secure_storage_linux` (старый `nlohmann/json.hpp`,
`-Werror=deprecated-literal-operator` на новых компиляторах; плюс сначала не хватало
`libsecret`/`pkg-config` в `flake.nix` — это уже добавлено). Сам `sqlite3`/drift на Dart VM
(native FFI) отработал корректно — это видно по `flutter test`, где `ResponseCacheDatabase`
реально открывает файл через нативный бэкенд без ошибок. Кто будет делать пункт «Linux/Windows
в CI» — начните с починки `flutter_secure_storage_linux` (обновить пакет или добавить
`-Wno-deprecated-literal-operator` в его CMake), это отдельная от кэша проблема.
+1 -2
View File
@@ -21,8 +21,7 @@ docker compose up -d db
docker compose exec -T db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean < backups/fintracker_<ts>.dump
docker compose up -d
```
Метрики пересчитываются из raw/core: `docker compose exec worker fintracker metrics refresh`
(появится в фазе 2).
Метрики пересчитываются из raw/core: `docker compose exec worker fintracker metrics refresh`.
## Секреты
+27
View File
@@ -523,6 +523,33 @@ backup/restore, страница здоровья (`/sync/status`, счётчи
Проверка: приложение работает offline на кэше; APK подписывается и ставится; restore из
ночного `pg_dump` на чистом VPS + `fintracker metrics refresh` воспроизводит все метрики.
**Статус: в работе.** Готово до этой правки (не с нуля):
адаптивные раскладки (`app_shell.dart`, breakpoints 600/1200), тёмная тема
(`theme_controller.dart`), общий паттерн пустых/ошибочных состояний
(`AsyncValueView` — 25 экранов, `EmptyState` — 22), backup — `pg-backup`-сайдкар в
`docker-compose.yml` (ночной `pg_dump -Fc`, retention 14 дней) и runbook в `ops.md`.
Сделано в этом заходе:
- **Offline-кэш** — контракт целиком в [offline-cache.md](docs/ai/offline-cache.md):
`CacheInterceptor` в `apiProvider` кэширует каждый успешный `GET` по `путь+параметры` в
`drift` (sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт `Cached<T>`, экран показывает один баннер «Нет соединения — данные на …».
Реализовано и протестировано целиком на `features/home/` (дашборд — самый частый экран);
остальные ~24 экрана переводятся по тому же контракту независимо друг от друга.
`app/web/sqlite3.wasm` + `app/web/drift_worker.js` закоммичены, пересборка —
`just app-web-assets`.
- `flutter build linux --debug` не пройден до конца: `flutter_secure_storage_linux` падает на
устаревшем `nlohmann/json.hpp` под `-Werror=deprecated-literal-operator` современных
компиляторов — не связано с кэшем, чинить отдельно перед пунктом CI/Linux. `libsecret` +
`pkg-config` для линуксовой сборки уже добавлены в `flake.nix`.
- `docs/ai/ops.md` поправлен: убрана ссылка на «появится в фазе 2» у `metrics refresh`.
Не начато: CI-джоба для Flutter (`just app-check` в CI, сборка Linux/Windows), консолидация
страницы здоровья (сейчас статусы синка на `/sync`, data quality — блоком на дашборде),
Android release-подпись (нужен keystore от пользователя), off-site бэкап (см. открытый
вопрос §7.9 — решения ещё нет).
## 7. Открытые вопросы (дефолт в скобках, можно менять по ходу)
1. Хранение refresh-токена в web: `localStorage` (дефолт: да, один пользователь, TLS) или