added docs and AGENTS.md to Balance for ios,macos,watchos

This commit is contained in:
wt
2026-09-08 12:53:44 +07:00
parent 579a572097
commit 896cc48128
7 changed files with 907 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
# AGENTS.md
## Назначение проекта
**Balance** — нативное мультиплатформенное приложение для учёта личных финансов:
- iOS 17+
- macOS 14+
- watchOS 10+
Основной UI написан на SwiftUI, локальное хранилище — SwiftData. Проект поддерживает два взаимоисключающих способа синхронизации данных: CloudKit или собственный HTTP(S)-сервер.
## Структура репозитория
```text
Balance/
├── Balance/ # Общий код iOS/macOS/watchOS
│ ├── App/ # Точка входа iOS и корневой ContentView
│ ├── Models/ # SwiftData-модели и доменные типы
│ ├── Shared/ # Общее состояние, контейнер SwiftData, server sync
│ ├── Utilities/ # Чистые функции расчётов и форматирование
│ └── Views/ # Общие SwiftUI-экраны
├── BalanceMac/ # macOS entry point и macOS-specific UI
├── BalanceWatch/ # watchOS entry point и компактный UI
├── BalanceTests/ # XCTest
├── Config/ # Entitlements
├── Balance.xcodeproj/
└── docs/ # Архитектура, модель данных, sync и dev guide
```
## Правила разработки
### 1. Сначала определяй слой изменения
- **Модель/данные:** `Balance/Models/`
- **Расчёты:** `Balance/Utilities/FinanceCalculations.swift`
- **Общий сервис:** `Balance/Shared/`
- **Общий UI:** `Balance/Views/`
- **Только Mac:** `BalanceMac/`
- **Только Watch:** `BalanceWatch/`
- **Тесты:** `BalanceTests/`
Не дублируй доменную логику в iOS/macOS/watchOS-экранах, если её можно выразить общей функцией или сервисом.
### 2. SwiftData
Текущая схема зарегистрирована в `BalanceModelContainer.schema`:
- `FinanceTransaction`
- `MonthlyBudget`
- `CustomCategory`
- `SyncTombstone`
При изменении `@Model` учитывай миграции существующих локальных баз. Не удаляй и не переименовывай persisted-поля без явной стратегии миграции.
### 3. Изменения финансовых записей
Любая редактируемая сервером сущность должна обновлять `syncUpdatedAt`.
Для удаления используй:
```swift
modelContext.deleteForSync(...)
```
а не прямой `modelContext.delete(...)`, если сущность должна удалиться и на других устройствах. Это создаёт tombstone в `SyncDeletionStore`.
### 4. Баланс и аналитика
`FinanceCalculations` содержит центральные правила:
- общий баланс = доходы − расходы;
- `isBalanceAdjustment == true` учитывается в общем балансе;
- корректировки не учитываются в периодической аналитике;
- расходы группируются по `categoryName`.
Не меняй эти правила только в одном UI. При изменении поведения обновляй `BalanceTests/FinanceCalculationsTests.swift`.
### 5. Категории
Системные категории находятся в `FinanceCategory`.
Пользовательские категории — `CustomCategory`. Их имя является важной частью текущей модели: операции и бюджеты хранят snapshot имени/иконки/emoji/цвета. При переименовании пользовательской категории текущий код обновляет связанные операции и бюджеты — это поведение нужно сохранять.
### 6. Синхронизация
`ServerAccountStore` — единая точка серверной авторизации и синхронизации.
Не хранить пароль в `UserDefaults`, SwiftData или файлах. Сессия хранится в Keychain.
При изменении URL сервера:
1. текущая сессия очищается;
2. cursor/push state удаляется;
3. пользователь должен авторизоваться заново.
Не включай одновременно CloudKit и собственный сервер как два источника синхронизации одной и той же базы.
### 7. Безопасность
- Для удалённого сервера использовать HTTPS.
- HTTP разрешён клиентом только для `localhost`, `127.0.0.1` и `::1`.
- Access/refresh tokens — только Keychain.
- Не логируй токены, пароли или полные Authorization headers.
- Не добавляй секреты, production URLs или credentials в репозиторий.
### 8. UI
Используй SwiftUI и системные компоненты Apple.
- Общие компоненты размещай в `Balance/Views/Components/`.
- Не тащи macOS-only API в общий код без `#if os(macOS)`.
- Не тащи watchOS-only API в общий код без условной компиляции.
- На Watch предпочитай короткие списки, `Form`, `List`, `NavigationStack` и компактные действия.
### 9. Тестирование
Минимум перед PR:
```bash
xcodebuild -project Balance.xcodeproj -scheme Balance -destination 'platform=iOS Simulator,name=<available iPhone>' test
```
Также полезно проверить Mac и Watch schemes:
```bash
xcodebuild -project Balance.xcodeproj -scheme BalanceMac -showBuildSettings
xcodebuild -project Balance.xcodeproj -scheme BalanceWatch -showBuildSettings
```
Если имя симулятора отличается, сначала получи список:
```bash
xcrun simctl list devices available
```
Не добавляй тесты, которые зависят от текущей даты или локали без явной фиксации `Calendar`, `Date` и formatter.
## Commit/PR checklist
- [ ] Изменение находится в правильном target/layer.
- [ ] Нет дублирования финансовой логики.
- [ ] Все серверные изменения сущностей обновляют `syncUpdatedAt`.
- [ ] Удаления синхронизируемых сущностей используют tombstone.
- [ ] Изменения расчётов покрыты XCTest.
- [ ] Проверены iOS/macOS/watchOS-условия компиляции.
- [ ] Не добавлены секреты и токены.
- [ ] Документация обновлена, если изменились данные, sync API или setup.
+12
View File
@@ -94,3 +94,15 @@ CloudKit является опциональным режимом. Для нег
## Устранение проблем сборки
После перехода с версии 2.0–2.0.2 выполните **Product → Clean Build Folder**. Если Xcode продолжает использовать старую конфигурацию watchOS target, закройте Xcode и удалите DerivedData для проекта `Balance`, затем снова откройте проект.
## Документация проекта
- [`AGENTS.md`](AGENTS.md) — правила для AI-агентов и разработчиков, структура проекта, invariants и checklist.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — архитектура приложения и ответственность слоёв.
- [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) — SwiftData-модели и финансовые инварианты.
- [`docs/SYNC.md`](docs/SYNC.md) — протокол авторизации и синхронизации с собственным сервером.
- [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — локальная разработка, сборка, тесты и signing.
- [`docs/PRODUCT.md`](docs/PRODUCT.md) — функциональная документация.
> Примечание: текущий архив содержит Apple-клиент Balance. Внешний Go-сервер, упомянутый в старой версии README, в этом архиве отсутствует; `docs/SYNC.md` документирует клиентский контракт по коду `ServerSync.swift`.
+139
View File
@@ -0,0 +1,139 @@
# Архитектура Balance
## Обзор
Balance использует простую feature-oriented структуру поверх SwiftUI + SwiftData:
```text
SwiftUI Views
│
├── FinanceCalculations
│
├── SwiftData ModelContext / @Query
│
└── ServerAccountStore
│
├── Keychain (session)
├── UserDefaults (server URL, cursor, sync metadata)
└── HTTP(S) API /v1/*
```
Общий код находится в `Balance/Balance/`. Платформенные оболочки находятся в `BalanceMac/` и `BalanceWatch/`.
## Точки входа
| Platform | Entry point | Основной root |
|---|---|---|
| iOS | `Balance/App/BalanceApp.swift` | `ContentView` |
| macOS | `BalanceMac/BalanceMacApp.swift` | `MacContentView` |
| watchOS | `BalanceWatch/BalanceWatchApp.swift` | `WatchRootView` |
Каждое приложение создаёт `BalanceModelContainer.make()` и передаёт его через `.modelContainer(...)`.
## Хранение данных
`BalanceModelContainer` выбирает конфигурацию:
1. если `BalanceCloudKitEnabled == true` и CloudKit container создаётся — SwiftData + CloudKit;
2. иначе — локальная SwiftData база;
3. если локальная база не создаётся — используется in-memory recovery container.
Recovery container предназначен для предотвращения падения приложения при невозможности открыть persistent store; он не является способом восстановления данных.
## Модели
### FinanceTransaction
Основная финансовая операция:
- UUID;
- сумма `Double`;
- дата;
- заметка;
- snapshot категории;
- тип `income` / `expense`;
- флаг корректировки баланса;
- `syncUpdatedAt`.
`TransactionKind` сериализуется через `kindRawValue`, а наружу предоставляется computed property `kind`.
### CustomCategory
Пользовательская категория:
- имя;
- SF Symbol или emoji;
- цвет;
- тип операции;
- `createdAt`;
- `syncUpdatedAt`.
### MonthlyBudget
Месячный лимит для категории:
- snapshot категории;
- `limit`;
- `monthStart`;
- `syncUpdatedAt`.
### SyncTombstone
SwiftData-модель для совместимости схемы, но клиентский журнал удалений фактически ведётся через `SyncDeletionStore` в `UserDefaults`. Это сделано специально, чтобы удаление не зависело от миграции основной SwiftData-схемы.
## Расчёты
`FinanceCalculations` — чистый доменный слой:
- `totalBalance` считает весь баланс;
- `monthInterval` возвращает календарный месяц;
- `summary` считает доходы/расходы периода;
- `spendingByCategory` агрегирует расходы.
Корректировки (`isBalanceAdjustment`) влияют на `totalBalance`, но исключаются из `summary` и `spendingByCategory`.
## UI/features
Общие features:
- Dashboard
- Transactions
- Budgets
- Analytics
- Categories
- Settings
- Balance Adjustment
- CSV export
- Server account/sync
macOS добавляет полноценную desktop-навигацию и расширенный аналитический экран.
watchOS использует отдельные экраны с теми же моделями и расчётами, но адаптированным UI.
## Состояние приложения
`@Query` используется как основной способ наблюдения за SwiftData. Feature views не должны создавать собственные параллельные кеши финансовых сущностей без необходимости.
Глобальное состояние серверного аккаунта сосредоточено в `ServerAccountStore.shared`.
Настройки пользователя хранятся через `@AppStorage`, в частности:
- `currencyCode`;
- `appTheme`;
- server URL;
- sync cursor;
- last push/sync timestamps.
## Принцип изменения
Предпочтительный поток:
```text
UI action
→ update/insert/delete SwiftData
→ update syncUpdatedAt / tombstone
→ UI обновляется через @Query
→ ServerAccountStore синхронизирует изменения
```
Не следует делать HTTP-запросы непосредственно из feature view.
+97
View File
@@ -0,0 +1,97 @@
# Модель данных
## Persisted schema
```text
FinanceTransaction
CustomCategory
MonthlyBudget
SyncTombstone
```
## FinanceTransaction
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | стабильный идентификатор |
| `amount` | Double | положительная сумма |
| `date` | Date | дата операции |
| `note` | String | заметка |
| `categoryName` | String | snapshot имени категории |
| `categoryIcon` | String | SF Symbol |
| `categoryEmoji` | String | emoji, если используется |
| `categoryColorName` | String | имя цвета |
| `isBalanceAdjustment` | Bool | специальная корректировка |
| `kindRawValue` | String | `income` / `expense` |
| `syncUpdatedAt` | Date | версия изменения |
## CustomCategory
| Поле | Тип |
|---|---|
| `id` | UUID |
| `name` | String |
| `icon` | String |
| `emoji` | String |
| `colorName` | String |
| `kindRawValue` | String |
| `createdAt` | Date |
| `syncUpdatedAt` | Date |
Имя пользовательской категории уникально без учёта регистра/диакритики относительно системных и других пользовательских категорий.
При редактировании категории текущая реализация распространяет новое имя и presentation metadata на связанные транзакции и бюджеты.
## MonthlyBudget
| Поле | Тип |
|---|---|
| `id` | UUID |
| `categoryName` | String |
| `categoryIcon` | String |
| `categoryEmoji` | String |
| `limit` | Double |
| `monthStart` | Date |
| `syncUpdatedAt` | Date |
Бюджет идентифицируется UUID, но связывается с категорией по сохранённому `categoryName`.
## SyncTombstone
| Поле | Тип |
|---|---|
| `id` | UUID |
| `entity` | String |
| `recordID` | String |
| `updatedAt` | Date |
В текущем клиентском sync-flow tombstone удалений хранится в `SyncDeletionStore` (`UserDefaults`) и отправляется как `deleted: true`.
Поддерживаемые entity:
```text
transaction
category
budget
```
## Финансовые инварианты
1. `amount` не должен быть отрицательным через пользовательский UI.
2. Тип операции хранится в `kindRawValue`.
3. Баланс:
`sum(income.amount) - sum(expense.amount)`.
4. Корректировка баланса является обычной транзакцией для общего баланса, но не участвует в периодической аналитике.
5. `syncUpdatedAt` должен меняться при каждом изменении синхронизируемой сущности.
6. Удаляемая синхронизируемая сущность должна создавать tombstone.
## Почему категория хранится snapshot'ом
Операция хранит `categoryName`, `categoryIcon`, `categoryEmoji` и `categoryColorName`, а не relationship на `CustomCategory`.
Это позволяет:
- не терять отображение старой операции при удалении категории;
- синхронизировать запись без SwiftData relationship;
- поддерживать системные категории без persisted object.
Из-за этого переименование пользовательской категории должно явно обновлять связанные записи — именно так работает текущая реализация.
+180
View File
@@ -0,0 +1,180 @@
# Разработка
## Требования
- Xcode 16+
- Swift 5
- iOS 17+
- macOS 14+
- watchOS 10+
В проекте нет Swift Package Manager-зависимостей.
## Открытие
```bash
open Balance.xcodeproj
```
Доступные shared schemes:
```text
Balance
BalanceMac
BalanceWatch
```
## Сборка
Сначала можно посмотреть схемы:
```bash
xcodebuild -project Balance.xcodeproj -list
```
Для CI используйте явный `-scheme` и подходящий `-destination`.
Пример проверки компиляции:
```bash
xcodebuild \
-project Balance.xcodeproj \
-scheme Balance \
-destination 'generic/platform=iOS' \
build
```
Для Mac:
```bash
xcodebuild \
-project Balance.xcodeproj \
-scheme BalanceMac \
-destination 'generic/platform=macOS' \
build
```
Для Watch:
```bash
xcodebuild \
-project Balance.xcodeproj \
-scheme BalanceWatch \
-destination 'generic/platform=watchOS' \
build
```
## Тесты
Основной тестовый target:
```text
BalanceTests
```
Запуск:
```bash
xcodebuild \
-project Balance.xcodeproj \
-scheme Balance \
-destination 'platform=iOS Simulator,name=<available iPhone>' \
test
```
Тесты сосредоточены на финансовых расчётах и sync deletion journal.
## Signing
Проект рассчитан на запуск с собственной Team.
Для обычной локальной разработки:
- выберите свою Team в Signing & Capabilities;
- используйте локальное SwiftData;
- CloudKit можно не включать.
Bundle identifiers текущего проекта:
```text
com.tolamironcenko.Balance
com.tolamironcenko.Balance.mac
com.tolamironcenko.Balance.watchkitapp
```
При публикации их следует заменить на собственные identifiers.
## Local mode
Без `BalanceCloudKitEnabled=YES` приложение использует локальное SwiftData-хранилище.
Это рекомендуемый режим для:
- UI-разработки;
- unit tests;
- Personal Team;
- отладки без Apple Developer Program.
## CloudKit mode
CloudKit является опциональным.
Нужно:
1. иметь платную Apple Developer Team;
2. создать собственный CloudKit container;
3. заменить placeholder container ID в entitlements;
4. включить iCloud/CloudKit capabilities;
5. выставить `BalanceCloudKitEnabled=YES`.
Не смешивайте CloudKit и собственный сервер как два активных sync backends для одной пользовательской базы.
## Server mode
В Settings укажите URL сервера, затем зарегистрируйтесь или войдите.
Для физического устройства удалённый адрес должен быть HTTPS.
Для локального сервера допустим:
```text
http://localhost:...
http://127.0.0.1:...
```
На физическом iPhone `localhost` означает сам iPhone, а не Mac-разработчика.
## Экспорт CSV
CSV export реализован в:
```text
Balance/Views/Settings/CSVDocument.swift
```
Экспорт доступен на iOS и macOS.
## Troubleshooting
### Xcode использует старые build artifacts
```text
Product → Clean Build Folder
```
Если проблема сохраняется, удалите DerivedData проекта и заново откройте Xcode.
### SwiftData schema error
Проверьте изменения `@Model` и не меняйте persisted fields без migration strategy.
### Sync не работает
Проверьте по порядку:
1. server URL;
2. HTTPS/localhost rule;
3. авторизацию;
4. дату последней синхронизации;
5. cursor;
6. совместимость версии Balance Server.
### Данные не удаляются на другом устройстве
Проверьте, что удаление выполнено через `deleteForSync(...)`, а не прямой `delete(...)`.
+101
View File
@@ -0,0 +1,101 @@
# Функциональная документация
## Dashboard
Показывает:
- общий баланс;
- доходы текущего месяца;
- расходы текущего месяца;
- последние операции;
- быстрый переход к аналитике, операциям, бюджетам, категориям, корректировке и настройкам.
## Операции
Поддерживаются:
- доходы;
- расходы;
- категория;
- сумма;
- дата;
- заметка;
- редактирование;
- удаление;
- поиск;
- фильтрация по типу.
## Корректировка баланса
Пользователь задаёт фактический баланс.
Разница между фактическим и текущим балансом сохраняется как специальная операция с `isBalanceAdjustment=true`.
Такая запись:
- влияет на общий баланс;
- не влияет на месячные income/expenses;
- не попадает в spending-by-category.
Это позволяет исправить расхождение между приложением и реальным счётом, не искажая историю расходов.
## Аналитика
Доступны периоды:
- месяц;
- 3 месяца;
- 12 месяцев.
Используются:
- доходы;
- расходы;
- результат;
- savings rate;
- структура расходов по категориям;
- динамика доходов и расходов.
## Бюджеты
Для категории можно задать месячный лимит.
Budget хранится отдельно от транзакций и синхронизируется через собственную entity `budget`.
## Категории
Системные категории разделены на income/expense.
Пользователь может создать свою категорию:
- название;
- тип;
- SF Symbol или emoji;
- цвет.
Имя категории не должно дублировать существующее имя.
## Настройки
- валюта: RUB, EUR, USD, SEK;
- тема: system/light/dark;
- сервер и синхронизация;
- CSV export на iOS/macOS.
## watchOS
Watch-приложение предоставляет компактные версии:
- Dashboard;
- Add/Edit transaction;
- All transactions;
- Analytics;
- Budgets;
- Categories;
- Balance adjustment;
- Settings.
## macOS
Mac-приложение предоставляет:
- dashboard;
- полную аналитику;
- операции;
- бюджеты;
- категории;
- корректировку;
- настройки;
- CSV export.
+232
View File
@@ -0,0 +1,232 @@
# Серверная синхронизация
## Назначение
`Balance/Shared/ServerSync.swift` реализует авторизацию и двустороннюю синхронизацию с собственным сервером.
Сервер не входит в этот Xcode-проект; клиент ожидает API под базовым URL сервера.
## Хранилище сессии
Сессия содержит:
- access token;
- refresh token;
- expiry;
- user id/email.
Сессия хранится в Keychain с service `BalanceServer`.
В `UserDefaults` хранятся только несекретные sync-настройки:
- server URL;
- device ID;
- cursor;
- last push;
- last sync.
Пароль клиента не сохраняется.
## Endpoints
Клиент использует POST JSON:
```text
POST /v1/auth/register
POST /v1/auth/login
POST /v1/auth/logout
POST /v1/auth/refresh
POST /v1/sync
```
Для авторизованных запросов используется:
```http
Authorization: Bearer <access-token>
Content-Type: application/json
```
При HTTP 401 клиент один раз обновляет access token через refresh endpoint и повторяет запрос.
## Валидация server URL
Разрешены:
- `https://...`
- `http://localhost...`
- `http://127.0.0.1...`
- `http://[::1]...`
Удалённый HTTP запрещён.
При смене адреса сервера клиент:
- удаляет сессию;
- удаляет sync cursor/push state;
- требует новую авторизацию.
## Sync request
Логическая форма:
```json
{
"cursor": 123,
"deviceId": "UUID",
"changes": [],
"pull": true
}
```
Для push `changes` заполнен, `pull=false`.
## Sync change
```json
{
"entity": "transaction",
"id": "UUID",
"deleted": false,
"updatedAt": "ISO-8601",
"payload": {}
}
```
Также сервер может возвращать `version` и `sequence`.
Поддерживаемые entity:
```text
transaction
category
budget
```
## Payloads
### transaction
```json
{
"amount": 1000,
"date": "2026-09-08T10:00:00.000Z",
"note": "Кофе",
"categoryName": "Кофе",
"categoryIcon": "cup.and.saucer.fill",
"categoryEmoji": "☕️",
"categoryColorName": "brown",
"isBalanceAdjustment": false,
"kindRawValue": "expense"
}
```
### category
```json
{
"name": "Кофе",
"icon": "cup.and.saucer.fill",
"emoji": "☕️",
"colorName": "brown",
"kindRawValue": "expense",
"createdAt": "2026-09-01T10:00:00.000Z"
}
```
### budget
```json
{
"categoryName": "Кофе",
"categoryIcon": "cup.and.saucer.fill",
"categoryEmoji": "☕️",
"limit": 5000,
"monthStart": "2026-09-01T00:00:00.000Z"
}
```
Даты кодируются как ISO-8601; клиент принимает варианты с fractional seconds и без них.
## Push
Клиент отправляет:
1. изменённые transactions;
2. изменённые categories;
3. изменённые budgets;
4. tombstones.
Размер batch — до 100 записей.
Если тело запроса превышает 1.5 MB, batch делится рекурсивно. Один payload больше 256 KB считается слишком большим.
## Pull
Клиент отправляет `pull=true` с cursor и применяет изменения по `sequence`.
После успешного применения сохраняется новый cursor.
Если сервер сообщает `hasMore=true`, клиент продолжает pagination до завершения.
Клиент защищается от:
- уменьшения cursor;
- `hasMore=true` без продвижения cursor.
## Conflict resolution
При применении входящего изменения запись обновляется только если:
```text
local.syncUpdatedAt < remote.updatedAt
```
Для удаления используется аналогичная проверка:
```text
local.syncUpdatedAt <= remote.updatedAt
```
Это простая last-write-wins стратегия на уровне timestamp.
## Deletions
Удаление локально:
```swift
modelContext.deleteForSync(transaction)
```
создаёт запись в `SyncDeletionStore`.
При sync она отправляется как:
```json
{
"entity": "transaction",
"id": "...",
"deleted": true,
"updatedAt": "..."
}
```
После получения соответствующего server change tombstone удаляется из локального журнала.
## Полная пересинхронизация
`resetSynchronization()` очищает cursor и last-push timestamp текущего server/user context, после чего выполняет обычный sync.
Это не удаляет локальные финансовые записи автоматически.
## Автосинхронизация
Apple clients запускают sync:
- после авторизации;
- при запуске/появлении root view;
- затем примерно каждые 120 секунд, пока цикл жив;
- вручную из Settings.
Фактическая фоновая работа всё равно зависит от lifecycle ОС.
## Совместимость
Клиент специально распознаёт `invalid_json` на `/v1/sync` и сообщает, что Balance Server нужно обновить до версии, совместимой с текущим архивом.
Клиент и сервер желательно обновлять согласованно.