kin: BATON-ARCH-002 Отключить SignalAggregator из v1
This commit is contained in:
parent
bb1a3b643a
commit
09b0deab2a
2 changed files with 344 additions and 0 deletions
153
docs/adr/ADR-005-frontend-stack.md
Normal file
153
docs/adr/ADR-005-frontend-stack.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# ADR-005: Выбор фронтенд-стека и i18n-стратегия
|
||||||
|
|
||||||
|
**Дата:** 2026-03-20
|
||||||
|
**Статус:** Accepted
|
||||||
|
**Автор:** Architect Agent (Kin pipeline, BATON-003)
|
||||||
|
**Решения:** #1026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Фронтенд Baton — PWA с одной кнопкой SOS. Функционал:
|
||||||
|
- Показать кнопку по центру экрана
|
||||||
|
- При первом визите — форма ввода имени (регистрация)
|
||||||
|
- При нажатии — вызов `/api/signal` с UUID, timestamp, geo (опционально)
|
||||||
|
- Service Worker для cache-first
|
||||||
|
- Адаптация под iOS/Android/Desktop
|
||||||
|
|
||||||
|
Пользователи из разных стран (300–400 человек) → вопрос о локализации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Варианты фронтенд-стека
|
||||||
|
|
||||||
|
### Вариант A: Vanilla JS (zero dependencies)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Нет build step: файлы деплоятся как есть
|
||||||
|
- Минимальный размер: app.js ~2–3 KB, style.css ~1–2 KB
|
||||||
|
- Нет транспиляции, бандлинга, node_modules
|
||||||
|
- Время загрузки: < 50ms на 3G
|
||||||
|
- Полный контроль над SW lifecycle (нет абстракций)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- Нет компонентной модели (не нужна для 1 экрана)
|
||||||
|
- Ручное управление DOM (getElementById, classList)
|
||||||
|
- Нет HMR при разработке
|
||||||
|
|
||||||
|
### Вариант B: Preact (~3 KB gzip)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- React-совместимый API в 3 KB
|
||||||
|
- JSX + компоненты
|
||||||
|
- Hooks для state management
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- Требует build step (Vite/esbuild)
|
||||||
|
- node_modules для 1 кнопки и 1 формы — overkill
|
||||||
|
- Добавляет ~3 KB к bundle без пользы
|
||||||
|
- Усложняет SW интеграцию (build output vs source)
|
||||||
|
|
||||||
|
### Вариант C: Vue 3 (используется в Kin)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Знакомость команды
|
||||||
|
- Мощный template синтаксис
|
||||||
|
- Экосистема (router, Pinia)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ~33 KB gzip (runtime)
|
||||||
|
- Обязателен build step
|
||||||
|
- Vue Router, Pinia, SFC — всё это не нужно для 1 экрана
|
||||||
|
- Кратный overkill: framework для 1 кнопки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение (фронтенд)
|
||||||
|
|
||||||
|
**Выбран Вариант A: Vanilla JS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обоснование (фронтенд)
|
||||||
|
|
||||||
|
1. **Один экран, одна кнопка.** Компонентная модель не даёт преимуществ когда весь UI — это кнопка + форма имени + сообщение об ошибке.
|
||||||
|
|
||||||
|
2. **Zero build step = zero complexity.** Файлы `index.html`, `app.js`, `style.css`, `sw.js`, `manifest.json` деплоятся напрямую. Нет Vite, нет esbuild, нет `npm run build`.
|
||||||
|
|
||||||
|
3. **Минимальный bundle.** Для экстренного приложения скорость загрузки критична. Vanilla JS: ~5 KB total. Vue 3: ~40 KB минимум. На 3G разница: 50ms vs 400ms.
|
||||||
|
|
||||||
|
4. **SW интеграция проще.** Service Worker precache список — конечный и известный заранее. С build step нужно интегрировать hashed filenames в SW.
|
||||||
|
|
||||||
|
5. **Preact/Vue отклонены** — обоснованный overkill. Если в будущем UI усложнится (v2: история сигналов, настройки, чат) — миграция на Preact за 2 часа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n-стратегия (#1026)
|
||||||
|
|
||||||
|
### Решение: i18n НЕ НУЖНА в v1
|
||||||
|
|
||||||
|
### Обоснование
|
||||||
|
|
||||||
|
**Анализ текстового контента UI:**
|
||||||
|
|
||||||
|
| Элемент | Текст | Универсальность |
|
||||||
|
|---|---|---|
|
||||||
|
| Кнопка | "SOS" или "HELP" | SOS — международный сигнал, не требует перевода |
|
||||||
|
| Заголовок | "Baton" | Название продукта, не переводится |
|
||||||
|
| Форма регистрации | "Your name" + placeholder | 1 строка, английский понятен целевой аудитории |
|
||||||
|
| Ошибка сети | "No connection" | 1 строка |
|
||||||
|
| Ошибка сервера | "Try again" | 1 строка |
|
||||||
|
|
||||||
|
**Итого: 4–5 строк текста**, из которых главная (SOS) универсальна.
|
||||||
|
|
||||||
|
**Целевая аудитория:** 300–400 пользователей из разных стран, но это не массовый consumer-продукт. Пользователи знают что это за приложение и как им пользоваться (установлено по прямой рекомендации).
|
||||||
|
|
||||||
|
### v2 (если потребуется)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Минимальный i18n: JSON + navigator.language
|
||||||
|
const LANG = navigator.language.slice(0, 2);
|
||||||
|
const T = translations[LANG] || translations['en'];
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл `translations.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"en": { "name_placeholder": "Your name", "no_connection": "No connection", "try_again": "Try again" },
|
||||||
|
"ru": { "name_placeholder": "Ваше имя", "no_connection": "Нет связи", "try_again": "Повторите" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Триггер перехода к i18n:** явный запрос от пользователей или расширение аудитории на non-English speaking массовый рынок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файловая структура фронтенда (v1)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── index.html # App shell, meta tags, apple-touch-icon, manifest link
|
||||||
|
├── app.js # UUID auth, geolocation, fetch /api/signal, error handling
|
||||||
|
├── style.css # Центрированная кнопка, responsive, dark theme
|
||||||
|
├── sw.js # Cache-first precache, skipWaiting, clientsClaim
|
||||||
|
├── manifest.json # PWA metadata (name, icons, display:standalone)
|
||||||
|
├── icon-180.png # iOS apple-touch-icon
|
||||||
|
├── icon-192.png # Android manifest (required)
|
||||||
|
└── icon-512.png # Android splash + maskable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
1. **Нет package.json в проекте.** Фронтенд не зависит от npm. Backend уже использует pip (requirements.txt).
|
||||||
|
|
||||||
|
2. **Деплой фронтенда = копирование файлов.** Nginx `root /path/to/frontend;` — готово. Нет CI/CD для сборки.
|
||||||
|
|
||||||
|
3. **Стили — один CSS файл.** Нет SCSS, нет PostCSS, нет CSS-in-JS. Для 1 экрана это достаточно.
|
||||||
|
|
||||||
|
4. **Тестирование фронтенда:** в v1 — ручное. Если потребуется автоматизация — Playwright (headless Chrome, без build step).
|
||||||
|
|
||||||
|
5. **i18n решение задокументировано явно (#1026).** При запросе локализации — план миграции готов (JSON файл + 3 строки JS).
|
||||||
191
docs/adr/ADR-006-offline-ios-constraints.md
Normal file
191
docs/adr/ADR-006-offline-ios-constraints.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# ADR-006: Офлайн-устойчивость v1 и ограничения iOS
|
||||||
|
|
||||||
|
**Дата:** 2026-03-20
|
||||||
|
**Статус:** Accepted
|
||||||
|
**Автор:** Architect Agent (Kin pipeline, BATON-003)
|
||||||
|
**Решения:** #1016, #1019, #1024, #1025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Два связанных вопроса:
|
||||||
|
|
||||||
|
1. **Offline queue:** если пользователь нажал SOS без сети — что происходит? Нужна ли очередь с повторной отправкой?
|
||||||
|
2. **iOS ограничения:** PWA standalone недоступен в EU (iOS 17.4 DMA)? 7-дневная очистка кеша? Приватный режим?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 1: Offline queue
|
||||||
|
|
||||||
|
### Варианты
|
||||||
|
|
||||||
|
#### Вариант A: Показать ошибку, нет retry (v1)
|
||||||
|
|
||||||
|
```
|
||||||
|
Нажатие → navigator.onLine === false → "Нет подключения. Проверьте сеть."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Ноль дополнительного кода
|
||||||
|
- Предсказуемое поведение: нет сети = нет сигнала
|
||||||
|
- Пользователь знает, что сигнал НЕ отправлен (нет ложной надежды)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- Сигнал потерян, если пользователь не нажмёт повторно
|
||||||
|
- navigator.onLine ненадёжен (может вернуть true при captive portal)
|
||||||
|
|
||||||
|
#### Вариант B: localStorage queue + online event
|
||||||
|
|
||||||
|
```
|
||||||
|
Нажатие → try fetch → fail → JSON.stringify в localStorage → online event → flush
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Простая реализация (~20 строк)
|
||||||
|
- Сигнал не теряется
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- iOS Safari private mode: localStorage недоступен (#1015)
|
||||||
|
- localStorage недоступен в Service Worker контексте
|
||||||
|
- При закрытии вкладки до online event — сигнал не отправлен (но сохранён)
|
||||||
|
|
||||||
|
#### Вариант C: IndexedDB + BackgroundSync (ADR-002 plan)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Самое надёжное: IndexedDB доступен из SW, BackgroundSync работает даже при закрытой вкладке (Chromium)
|
||||||
|
- Idempotency key защищает от дубликатов
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- IndexedDB API громоздкий
|
||||||
|
- BackgroundSync: 78.75% покрытие (Safari/Firefox не поддерживают)
|
||||||
|
- Сложность x10 по сравнению с Вариантом A
|
||||||
|
|
||||||
|
### Решение (offline)
|
||||||
|
|
||||||
|
**Выбран Вариант A для v1 (#1019): показать ошибку, нет retry.**
|
||||||
|
|
||||||
|
Переход на Вариант C (IndexedDB + BackgroundSync) запланирован для v2 (полная спека в ADR-002).
|
||||||
|
|
||||||
|
### Обоснование (offline)
|
||||||
|
|
||||||
|
1. **#1019: cache-first SW без offline queue достаточен для low-load PWA v1.** Приложение открывается мгновенно (кешированные статические файлы). Если сети нет — пользователь видит ошибку и повторяет попытку когда сеть вернётся.
|
||||||
|
|
||||||
|
2. **Частота использования: ~1 раз/неделю.** Вероятность "нажал SOS без сети и забыл повторить" — крайне низкая. Экстренная ситуация мотивирует повторную попытку.
|
||||||
|
|
||||||
|
3. **navigator.onLine проверка + UX:**
|
||||||
|
```javascript
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
showError('Нет подключения. Проверьте сеть и попробуйте снова.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendSignal();
|
||||||
|
showSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
showError('Ошибка отправки. Попробуйте снова.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Даже если `navigator.onLine` ненадёжен — try/catch ловит ошибку fetch.
|
||||||
|
|
||||||
|
4. **Вариант C (IndexedDB + BackgroundSync) — зарезервирован для v2.** ADR-002 содержит полную спецификацию. Переход потребует ~4 часа dev-работы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 2: iOS 17.4 EU DMA (#1016)
|
||||||
|
|
||||||
|
### Хронология
|
||||||
|
|
||||||
|
| Дата | Событие |
|
||||||
|
|---|---|
|
||||||
|
| Январь 2024 | Apple анонсировала удаление PWA standalone в EU (iOS 17.4 beta) |
|
||||||
|
| 1 марта 2024 | Apple отменила решение после массовой критики и EC inquiry |
|
||||||
|
| iOS 17.4 release | PWA standalone работает в EU без ограничений |
|
||||||
|
| Март 2026 (сейчас) | PWA standalone работает в EU на iOS 17.x, 18.x |
|
||||||
|
|
||||||
|
### Текущий статус
|
||||||
|
|
||||||
|
**PWA standalone режим РАБОТАЕТ в EU на всех актуальных версиях iOS.**
|
||||||
|
|
||||||
|
### Что НЕ работает в EU на iOS
|
||||||
|
|
||||||
|
| Функция | Статус в EU | Влияние на Baton |
|
||||||
|
|---|---|---|
|
||||||
|
| PWA standalone | ✅ Работает | Нет влияния |
|
||||||
|
| Push notifications | ❌ Не работает | Нет влияния (Baton не использует push в v1) |
|
||||||
|
| Background Sync | ❌ Не поддерживается Safari вообще | Нет влияния (v1 без offline queue) |
|
||||||
|
|
||||||
|
### Workaround (на случай повторного удаления)
|
||||||
|
|
||||||
|
Если Apple снова уберёт standalone в EU:
|
||||||
|
|
||||||
|
1. **manifest.json:** `"display": "standalone"` → `"display": "browser"`
|
||||||
|
- Приложение откроется в Safari tab вместо fullscreen
|
||||||
|
- Функциональность сохраняется полностью
|
||||||
|
- UX деградирует: видна адресная строка
|
||||||
|
|
||||||
|
2. **Telegram Web App (альтернатива):**
|
||||||
|
- Baton как Telegram Mini App
|
||||||
|
- Не зависит от PWA standalone
|
||||||
|
- Ограничение: работает только внутри Telegram
|
||||||
|
|
||||||
|
3. **Нативная обёртка (крайний случай):**
|
||||||
|
- Capacitor/Cordova → IPA → TestFlight
|
||||||
|
- Для 300-400 пользователей — Enterprise distribution или Ad Hoc provisioning
|
||||||
|
- Overkill, только если Apple полностью заблокирует PWA
|
||||||
|
|
||||||
|
**Рекомендация:** не предпринимать действий сейчас. Мониторить iOS release notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 3: iOS 7-дневная очистка кеша
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
WebKit (Safari) очищает все website data (включая Service Worker registration, Cache API, localStorage, IndexedDB) после **7 дней непрерывного неиспользования** домена.
|
||||||
|
|
||||||
|
### Влияние на Baton
|
||||||
|
|
||||||
|
| Что теряется | Последствие | Серьёзность |
|
||||||
|
|---|---|---|
|
||||||
|
| SW registration | При следующем открытии — SW re-registers (нужна сеть) | Низкая |
|
||||||
|
| Cache API (precached assets) | Первое открытие после 7 дней — загрузка из сети | Низкая |
|
||||||
|
| localStorage (UUID) | Пользователь получает новый UUID → повторная регистрация | Средняя |
|
||||||
|
|
||||||
|
### Митигация
|
||||||
|
|
||||||
|
1. **Подсказка при установке:** "Открывайте приложение хотя бы раз в неделю для надёжной работы"
|
||||||
|
2. **UUID на сервере:** при потере localStorage UUID — пользователь регистрируется заново. Это неидеально, но приемлемо (знаем имя, не знаем что это тот же человек).
|
||||||
|
3. **v2 mitigation:** привязка Telegram user ID (через /start в боте) как persistent identifier, не зависящий от browser storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 4: iOS Safari Private Mode (#1015)
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
`localStorage.setItem()` бросает `SecurityError` в приватном режиме iOS Safari.
|
||||||
|
|
||||||
|
### Решение (реализовано в ADR-003)
|
||||||
|
|
||||||
|
Цепочка fallback (#1025): `localStorage → sessionStorage → in-memory`
|
||||||
|
|
||||||
|
Проверка доступности через реальную запись (#1024), не `typeof`.
|
||||||
|
|
||||||
|
### Поведение в private mode
|
||||||
|
|
||||||
|
- UUID генерируется в `sessionStorage` или `in-memory`
|
||||||
|
- UUID живёт до закрытия вкладки (sessionStorage) или до перезагрузки (in-memory)
|
||||||
|
- При следующем открытии — новый UUID, новая регистрация
|
||||||
|
- **Это ожидаемое поведение.** Пользователь в приватном режиме осознанно выбирает непостоянство данных.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
1. **v1 offline = show error.** Минимальная сложность, максимальная прозрачность для пользователя.
|
||||||
|
2. **iOS DMA = no action needed.** Standalone работает. Workaround задокументирован.
|
||||||
|
3. **7-day cache = acceptable risk.** Для экстренного приложения пользователь скорее всего открывает его чаще 1 раза в неделю (хотя бы для проверки).
|
||||||
|
4. **Private mode = degraded but functional.** UUID временный, но SOS работает.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue