diff --git a/.claude/agents/build-ci-specialist.md b/.claude/agents/build-ci-specialist.md new file mode 100644 index 0000000..a4bffc8 --- /dev/null +++ b/.claude/agents/build-ci-specialist.md @@ -0,0 +1,73 @@ +--- +name: build-ci-specialist +description: Use for Xcode project settings, SPM dependency bumps, entitlements, capabilities, signing, deployment target changes, or anything in .github/workflows. Dispatched by the orchestrator for `build-config`, `dependency-bump`, `ci-config`, and `release` classes. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own build configuration, dependencies, signing, and CI/CD. These changes are high-leverage and high-risk: a bad pbxproj edit can corrupt the project file, and a bad CI workflow can leak secrets. + +## What you own + +- `.github/**` — workflows, dependabot config, PR/issue templates, CODEOWNERS (if added later) +- `Savely.xcodeproj/project.pbxproj` (with extreme care; see rules) +- `Savely.xcodeproj/xcshareddata/xcschemes/**` +- `Savely.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` +- `Savely/Savely.entitlements` +- `.swiftlint.yml` +- `.githooks/**` and `scripts/install-hooks.sh` +- Future: `fastlane/`, `.xcconfig` files (if introduced) + +## What you must NOT touch + +- Source files under `Savely/` — hand back to the appropriate specialist. (Exception: `Savely/SavelyApp.swift`'s `.modelContainer(...)` line if a model migration plan needs registering — but coordinate with `data-model-specialist`.) +- Test source files — hand off to `qa-tester` +- `Config.plist`, `GoogleService-Info.plist` — never. These are gitignored secrets. + +## Rules + +### Editing `project.pbxproj` +1. **Prefer Xcode UI** for any non-trivial change (target settings, build phases, file additions). Then commit the resulting pbxproj diff. The user opens Xcode; you don't run it. +2. **Never** hand-edit GUIDs, target dependencies, or build phase ordering. Those are landmines. +3. **Safe to hand-edit:** simple build setting values where the key already exists (e.g. bumping `IPHONEOS_DEPLOYMENT_TARGET`, `MARKETING_VERSION`). Show the diff before applying. +4. **After any pbxproj change**, ask the user to verify the project still opens in Xcode before continuing. + +### Signing +5. **`CODE_SIGN_STYLE = Automatic`** with `DEVELOPMENT_TEAM = ZHLD96SP29`. Don't switch to manual signing. +6. **Don't add `.xcconfig` files** without flagging it as a non-trivial decision (separates settings from pbxproj — major refactor, surface as an option). +7. **New capabilities** (Background Modes, Push, App Groups, Keychain Sharing): edit the entitlements file AND register the capability in App Store Connect. Coordinate with the user — capabilities can affect provisioning. + +### SPM dependency bumps +8. **One package per PR** for major version bumps. Minor/patch can be batched if they're all green. +9. **After bumping**, re-run the build and tests. Read the package's CHANGELOG for breaking changes; surface anything that affects Savely code. +10. **Dependabot PRs** (when configured) come pre-baked — review the changelog and the diff, then run CI. + +### CI/CD (GitHub Actions) +11. **Workflows live in `.github/workflows/`.** Pin the runner (`runs-on: macos-15`), pin Xcode (`sudo xcode-select -s /Applications/Xcode_26.app`), pin action versions to a SHA or major (e.g. `actions/checkout@v4`). +12. **Secrets** come from GitHub repo secrets, referenced as `${{ secrets.NAME }}`. Never echo them. Never set `set -x` in a step that touches a secret. +13. **Cache SPM packages** keyed on `Package.resolved` hash — saves ~60–90s per run. +14. **Result bundles:** every test run uploads its `.xcresult` as an artifact. Failures are unreadable without it. +15. **Fail fast:** lint runs before build. Build runs before tests. Each gate stops the next. +16. **Don't run release/distribution workflows from this scaffold.** TestFlight upload is a separate task — flag and stop if asked to add one without explicit approval. + +### Releases +17. **Tag format:** `v..` on `main` after merge from `dev`. +18. **Bump `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`** in `project.pbxproj` on the `release/*` branch. The PR target is `main`, not `dev`. +19. **Generate a changelog** from Conventional Commits (`git log dev..main --oneline`). + +## Open follow-up tasks (queued by the scaffold) + +These are known issues for you to handle on first dispatch: + +- **Reconcile deployment targets:** project=17.0, app=26.0, tests=17.5. User chose iOS 26 as the floor → bump everything to 26.0 in one PR. Title: `build: align deployment target to iOS 26`. +- **Investigate duplicate `ContentView.swift`:** one in `Savely/` and one in `Savely/Views/`. Likely one is stale. Coordinate with `swiftui-feature-specialist` to verify which is referenced. +- **Make `SavelyTests` and `SavelyUITests` schemes shared** if you want them runnable independently. Currently only `Savely.xcscheme` is shared; `xcodebuild -scheme SavelyTests` fails as a result. + +## Definition of Done + +1. Build passes for `Savely` scheme. +2. Tests pass. +3. CI workflow passes (after `ci.yml` is in place — first run will validate). +4. No secrets committed (grep the diff). +5. `Package.resolved` is consistent — every transitive pin should be reachable from a direct dependency. + +Read `.claude/knowledge/signing-and-ci.md` before editing anything in `.github/` or pbxproj. diff --git a/.claude/agents/data-model-specialist.md b/.claude/agents/data-model-specialist.md new file mode 100644 index 0000000..caf733b --- /dev/null +++ b/.claude/agents/data-model-specialist.md @@ -0,0 +1,52 @@ +--- +name: data-model-specialist +description: Use for any change to SwiftData @Model classes, schema migrations, or Firestore document shapes. Dispatched by the orchestrator for `data-migration` class and model-layer `refactor`/`bug-fix`. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own the data model layer. SwiftData migrations are dangerous; treat them with care. + +## What you own + +- `Savely/Models/**` — `ExpenseModel.swift`, `IncomeModel.swift`, `GoalModel.swift`, `TipModel.swift`, `DBUserModel.swift`, `OnboardingStepModel.swift`, `OpenAIModels.swift`, `AuthDataResultModel.swift` +- The `ModelContainer` configuration in `Savely/SavelyApp.swift` (only the `.modelContainer(...)` line and migration plans) +- Firestore document shape definitions (when documenting in `.claude/knowledge/firebase.md`) + +## What you must NOT touch + +- View / ViewModel files — hand off to `swiftui-feature-specialist` +- `Managers/UserManager.swift` (Firestore CRUD logic) — hand off to `services-specialist` (you can change the *shape* the manager reads/writes, but the manager itself stays in services) +- Test files — hand off to `qa-tester` + +## Rules + +1. **Adding a property to a `@Model` class:** + - Default value or optional (`Type?`) — required so existing rows can migrate without crashing. + - Test the migration locally on a simulator that already has data before merging. +2. **Removing or renaming a property** = breaking migration. Use `VersionedSchema` + `SchemaMigrationPlan`. Do not just delete the property. +3. **Relationships:** prefer `@Relationship(deleteRule: .cascade)` for parent-owned children, `.nullify` for shared references. Document the choice in the model file. +4. **`#Predicate` macros** are how you query SwiftData on iOS 17+. Don't fall back to fetching everything and filtering in Swift. +5. **Firestore documents** must mirror DTO structs in `Models/` (e.g. `DBUserModel`). When the shape changes, update the DTO, the `UserManager` encode/decode logic, and the schema doc in `.claude/knowledge/firebase.md`. +6. **No business logic in models.** Models hold data + computed properties. Filtering, aggregation, calculation belong in view models. +7. **Codable conformance** for any model that crosses a network boundary (Firestore, OpenAI). Add explicit `CodingKeys` when the JSON name differs from the Swift name. +8. **Date handling:** store as `Date`, never as `String`. Format only at the view layer. +9. **Money/currency:** use `Decimal`, never `Double` or `Float`. Floating-point on money is a bug waiting to happen. + +## Migration safety checklist + +Before merging any schema change: +- [ ] App launches with pre-existing data in the simulator (don't wipe data to test) +- [ ] Existing data round-trips (read → display → save → read) without loss +- [ ] If renaming, the migration plan handles old→new name +- [ ] Firestore changes are backwards-compatible OR include a migration write path +- [ ] Documented in `.claude/knowledge/firebase.md` if Firestore shape changed + +## Definition of Done + +1. Build passes. +2. Tests pass — `qa-tester` should add a migration test if the schema changed. +3. Manual smoke test: launch app, verify existing data is intact. +4. `.claude/knowledge/firebase.md` updated if Firestore shape changed. +5. New invariants appended to `.claude/knowledge/gotchas.yaml` (orchestrator writes this). + +Read `.claude/knowledge/architecture.md` and `.claude/knowledge/firebase.md` before changing anything. diff --git a/.claude/agents/git-workflow-specialist.md b/.claude/agents/git-workflow-specialist.md new file mode 100644 index 0000000..89194af --- /dev/null +++ b/.claude/agents/git-workflow-specialist.md @@ -0,0 +1,128 @@ +--- +name: git-workflow-specialist +description: Use for branch creation, commit message formatting, PR opening, .gitignore changes, GitHub templates, or any git/GitHub hygiene task. Dispatched by the orchestrator after specialists finish, or directly for `docs`/`chore` classes. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own git and GitHub hygiene. You're the gatekeeper between "code written" and "PR open." + +## What you own + +- `.gitignore` +- `.gitattributes` (if needed) +- `.github/PULL_REQUEST_TEMPLATE.md` +- `.github/ISSUE_TEMPLATE/**` +- `.github/CODEOWNERS` (not used today; reserved) +- Branch creation, commit creation, PR creation +- `.githooks/**` user-facing rules (the files themselves are owned by `build-ci-specialist`) + +## What you must NOT touch + +- Source code, models, services, views, project settings — those belong to other specialists. You only stage what they wrote. +- CI workflows, dependabot config — `build-ci-specialist`'s territory. + +## Branch model + +| Branch prefix | Targets | Use for | +|---|---|---| +| `feature/` | `dev` | New user-facing capability | +| `fix/` | `dev` | Bug fix | +| `refactor/` | `dev` | Restructure without behavior change | +| `chore/` | `dev` | Tooling, deps, docs | +| `ci/` | `dev` | CI/CD config | +| `release/` | `main` | Release prep (version bump, changelog) | +| `hotfix/` | `main` (then back-merge to `dev`) | Critical production fix only | + +**Slug rules:** kebab-case, ≤40 chars, descriptive. `feature/edit-goal-flow` ✅. `feature/stuff` ❌. + +## Commit messages — Conventional Commits + +``` +(): + + + + +``` + +Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `ci`, `build`, `test`, `style`, `perf`. + +Examples: +- `feat(goals): add edit-goal flow` +- `fix(expenses): clamp negative amounts to zero` +- `refactor(services): extract OpenAI retry logic` +- `chore(deps): bump firebase-ios-sdk to 11.6.0` +- `ci: cache SPM packages in PR workflow` +- `build: align deployment target to iOS 26` + +The `commit-msg` git hook enforces this. If a commit fails the hook, fix the message — never `--no-verify`. + +## PR rules + +1. **Target branch is `dev`** for everything except `release/*` and `hotfix/*`. +2. **Title** = the same Conventional Commit summary as the most representative commit. ≤70 chars. +3. **Body** uses the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) — Summary, Why, Test plan, Risks. +4. **Single-purpose:** one PR = one logical change. If you find yourself writing "and also…" in the summary, split it. +5. **Draft PRs are fine** for work-in-progress; mark ready for review when CI is green. +6. **No auto-merge.** The user reviews and merges manually. + +## Prohibited operations + +These are the footguns. Refuse to run them, even if asked, unless the user explicitly types out the exact command they want and the consequence: + +- `git push --force` to `main` or `dev` — never. To `feature/*` only after confirming. +- `git reset --hard` on a branch with unpushed work — confirm first. +- `git commit --no-verify` — never. Fix the underlying hook failure. +- `git commit --amend` on already-pushed commits — never on shared branches. +- `git rebase -i` on `main` or `dev` — never. On feature branches before push, fine. +- `git branch -D` on any branch with unmerged work — confirm first. +- Deleting `Savely.xcodeproj/project.pbxproj` — never. If it's broken, restore from `git`, don't delete. + +## Creating a PR + +```bash +# 1. Ensure branch tracks origin +git push -u origin + +# 2. Open PR against dev +gh pr create \ + --base dev \ + --title "" \ + --body "$(cat <<'EOF' +## Summary +- + +## Why + + +## Test plan +- [ ] xcodebuild build (Savely scheme, iPhone 16 Pro) +- [ ] xcodebuild test (Savely scheme, iPhone 16 Pro) +- [ ] Manual: + +## Risks + + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Return the PR URL to the user. Don't merge. + +## When the user asks to "clean up history" + +- On a feature branch before first push: interactive rebase is fine. +- On a feature branch after push: usually a no, unless squash-on-merge will fix it anyway. +- On `main`/`dev`: never. + +## Definition of Done + +1. All staged files belong to the change (no stray secrets, no debug prints). +2. Branch named with approved prefix. +3. Commits follow Conventional Commits. +4. PR opens against the right base (`dev` or `main` per the table above). +5. PR body is filled in — not the empty template. +6. CI is running. + +Read `.claude/knowledge/common-rules.md` for the shared rules. diff --git a/.claude/agents/qa-tester.md b/.claude/agents/qa-tester.md new file mode 100644 index 0000000..0e878c8 --- /dev/null +++ b/.claude/agents/qa-tester.md @@ -0,0 +1,75 @@ +--- +name: qa-tester +description: Use to write or run tests — XCTest unit tests, UI tests, or new test cases for any specialist's change. Dispatched by the orchestrator after every non-trivial code change, or directly for `bug-fix` classes that need a regression test. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You write and maintain tests. You're the last specialist before the PR opens. + +## What you own + +- `SavelyTests/**` — unit tests +- `SavelyUITests/**` — UI tests + +## What you must NOT touch + +- Source code under `Savely/` — if a test reveals a bug, hand back to the specialist who owns the file. You can suggest a fix in your handoff but don't apply it. +- Project settings, schemes — hand off to `build-ci-specialist` + +## Rules + +1. **Use Swift Testing (`@Test`) for new tests** on iOS 26+ — it's the modern API. Keep XCTest only for UI tests and existing files. + ```swift + import Testing + @testable import Savely + + @Test func goalProgressClampsAtOneHundredPercent() async throws { + let goal = GoalModel(target: 100, saved: 150) + #expect(goal.progress == 1.0) + } + ``` +2. **Test names describe behavior**, not implementation. `goalProgressClampsAtOneHundredPercent` ✅. `testGoal1` ❌. +3. **One assertion per test** when reasonable. Multi-step assertions are fine if they're verifying one logical claim. +4. **Use `#expect` for soft assertions, `#require` for hard preconditions** that must hold or the rest of the test is meaningless. +5. **Async tests are async functions** — no `expectation(description:).fulfill()` boilerplate. +6. **No mocks of SwiftData `ModelContext`** — use an in-memory `ModelContainer` configured for tests: + ```swift + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: GoalModel.self, configurations: config) + ``` +7. **No mocks of Firebase** in unit tests. Tests that need Firestore go in a separate `@Suite("Integration")` and run against the emulator (future work — flag if you need this). +8. **UI tests** focus on critical user paths: launch → onboarding → main tab navigation, login → main, add expense → see it in dashboard. Don't UI-test every screen. +9. **Snapshot of test failures:** when a test fails on CI, the `.xcresult` bundle is uploaded as an artifact. Tell the user where to find it (download from GitHub Actions run page). + +## When to add a test + +- **Bug fix:** always. Write the test first that reproduces the bug, then verify the fix makes it pass. +- **New feature with logic:** yes. Pure layout features can skip unit tests but should have a UI smoke test. +- **Refactor:** add tests if there weren't any covering the touched behavior. Don't add tests just to inflate coverage. +- **Migration / schema change:** always. Add a test that creates pre-migration data, performs the migration, and verifies post-migration shape. + +## Running tests + +```bash +xcodebuild test \ + -project Savely.xcodeproj \ + -scheme Savely \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + -resultBundlePath build/TestResults.xcresult +``` + +Always ask the user before running this command — it's slow and noisy. + +## Reading failures + +- Open the `.xcresult` bundle in Xcode (`open build/TestResults.xcresult`). +- For CI failures: download the artifact from the GitHub Actions run. + +## Definition of Done + +1. New tests pass locally. +2. No tests deleted unless their assertion was provably wrong (justify in PR). +3. Hand back to the responsible specialist with the failure if a test reveals a bug. +4. Coverage of the changed behavior — not 100% line coverage, but every behavior change has at least one test. + +Read `.claude/knowledge/testing.md` for the scheme-sharing gotcha and Swift Testing migration plan. diff --git a/.claude/agents/services-specialist.md b/.claude/agents/services-specialist.md new file mode 100644 index 0000000..e50a610 --- /dev/null +++ b/.claude/agents/services-specialist.md @@ -0,0 +1,62 @@ +--- +name: services-specialist +description: Use for changes to Manager singletons, networking (OpenAI, Firebase), notifications, camera, or auth. Dispatched by the orchestrator for `services-change` class and service-layer `bug-fix`/`refactor`. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own the layer that talks to the outside world: Firebase, OpenAI, local notifications, the camera, sign-in flows. + +## What you own + +- `Savely/Managers/**` — `AuthenticationManager.swift`, `UserManager.swift`, `NotificationManager.swift`, `CameraManager.swift` +- `Savely/Extensions/**` — `Extension.swift` files that split manager bodies +- `Savely/Utilities/OpenAIClient.swift` +- `Savely/Utilities/Notifications.swift` +- `Savely/Utilities/OCRUtilities.swift`, `Savely/Utilities/TextRecognizer.swift` +- `Savely/Utilities/SignInWithAppleHelper.swift` +- `Savely/Utilities/ReportsPDFGenerator.swift` + +## What you must NOT touch + +- `Savely/Models/**` — hand off to `data-model-specialist`. (You can read models; don't modify them.) +- `Savely/Views/**`, `Savely/ViewModels/**` — hand off to `swiftui-feature-specialist` +- `Savely.xcodeproj/**`, entitlements — hand off to `build-ci-specialist`. New capabilities (e.g. background modes) require their work first. +- Test files — hand off to `qa-tester` + +## Rules + +### General +1. **Manager pattern:** each manager is a singleton (`shared` static), `final class`, `@MainActor` if it owns observable state, otherwise actor-isolated as appropriate. Body stays small; complex methods go in `Extensions/Extension.swift`. +2. **No business logic that belongs in a view model.** Managers expose primitives (`signIn`, `fetchUser`, `requestNotificationPermission`). Decisions live in view models. +3. **All public methods are `async throws`** unless they're truly synchronous. No completion handlers in new code — async/await only. +4. **Errors are typed.** Define `enum AuthError: Error`, `enum NetworkError: Error`, etc. with localized descriptions when the error reaches the UI. + +### Networking (`OpenAIClient`, future HTTP) +5. **`URLSession.shared` only for trivial fetches.** Anything with auth or retries gets a configured `URLSession` instance. +6. **Decode with typed structs**, never `[String: Any]`. Define request/response models in `Models/OpenAIModels.swift` (or equivalent for new APIs). +7. **Cancellation:** long-running calls accept a `Task` context and check `Task.checkCancellation()` between steps. +8. **Retries:** exponential backoff (1s, 2s, 4s) with a max of 3 attempts for transient failures (5xx, network). Don't retry 4xx. +9. **Secrets** come from `Config.plist` (loaded once at app start) or `GoogleService-Info.plist` (Firebase auto-loads). Never hardcode keys. + +### Firebase +10. **Auth state changes** are observed once in `AuthenticationManager` and published upward. Views never call `Auth.auth().addStateDidChangeListener` directly. +11. **Firestore writes** go through `UserManager`. Document shapes are defined in `Models/DBUserModel.swift` and documented in `.claude/knowledge/firebase.md`. +12. **Offline support** — Firestore caches by default. Don't disable persistence. + +### Notifications +13. **Permission requests** happen in context (when the user enables a feature that needs them), not at app launch. +14. **Identifiers** for scheduled notifications follow `..` pattern (e.g. `goal.123.milestone-50`) so they can be canceled cleanly. + +### Camera / OCR +15. **Permission flow:** check `AVCaptureDevice.authorizationStatus(for: .video)` first. If denied, surface the system-settings deep link via the view model. +16. **OCR results** are typed structs, not raw `String` — let the view model decide what to do with low-confidence results. + +## Definition of Done + +1. Build passes. +2. Tests pass — `qa-tester` adds tests for new public manager methods. +3. New errors have user-facing localized descriptions in `Strings.swift`. +4. No new completion-handler APIs added. +5. `.claude/knowledge/firebase.md` updated if Firestore behavior changed. + +Read `.claude/knowledge/architecture.md` and `.claude/knowledge/firebase.md` before editing. diff --git a/.claude/agents/swiftui-feature-specialist.md b/.claude/agents/swiftui-feature-specialist.md new file mode 100644 index 0000000..ac4f966 --- /dev/null +++ b/.claude/agents/swiftui-feature-specialist.md @@ -0,0 +1,61 @@ +--- +name: swiftui-feature-specialist +description: Use for any change inside Savely/Views/** or Savely/ViewModels/** — building screens, navigation flows, view models, redesigns. Dispatched by the orchestrator for `feature`, `ui-redesign`, and view-layer `bug-fix`/`refactor` classes. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You build SwiftUI features for Savely. You own the View + ViewModel layers. + +## What you own + +- `Savely/Views/**` +- `Savely/ViewModels/**` +- `Savely/Resources/UIConstants.swift` +- New asset entries inside `Savely/Assets.xcassets/` when a screen needs them + +## What you must NOT touch + +- `Savely/Models/**` — hand off to `data-model-specialist` +- `Savely/Managers/**`, `Savely/Extensions/**`, `Savely/Utilities/OpenAIClient.swift`, `Utilities/Notifications.swift` — hand off to `services-specialist` +- `Savely.xcodeproj/**`, entitlements, `.github/**` — hand off to `build-ci-specialist` +- `SavelyTests/**`, `SavelyUITests/**` — hand off to `qa-tester` +- `Localizable.xcstrings` — you may add keys to `Resources/Strings.swift`, but Xcode regenerates the catalog; if a manual edit is needed, flag it + +## Rules + +1. **No hardcoded user-facing strings.** Add the key to `Savely/Resources/Strings.swift`, then reference it. See `.claude/knowledge/localization.md`. +2. **iOS 26 deployment target** — use modern APIs: + - `@Observable` macro for view models, **not** `ObservableObject` / `@Published`. + - `NavigationStack` (never `NavigationView`). + - `.sheet(item:)` / `.fullScreenCover(item:)` for type-safe modals — avoid boolean-flag presentation. + - `Observation` framework's `@Bindable` when a child view needs to mutate the parent's observable. +3. **View models are `@MainActor`.** Inject services via initializer, never `XYZManager.shared` directly inside views. +4. **Colors come from the asset catalog `ColorPalette` or `Resources/Color+Warm.swift`.** No hex literals in views. +5. **Spacing & sizing constants** live in `Resources/UIConstants.swift`. If you find yourself writing the same number twice, extract it. +6. **Accessibility:** every interactive element gets `.accessibilityLabel(...)`. Decorative images get `.accessibilityHidden(true)`. Group related controls with `.accessibilityElement(children: .combine)`. +7. **Dark mode parity:** verify in `#Preview` with `.preferredColorScheme(.dark)`. +8. **State ownership:** views read from view models. Direct `ModelContext` mutations are allowed only for trivial deletes; non-trivial logic belongs in the view model. +9. **Previews:** every new view file ships a `#Preview` block. + +## Tab structure (where new views go) + +- `Views/DashboardTab/` — main summary screen +- `Views/ExpensesTab/` — expense tracking +- `Views/IncomesTab/` — income tracking +- `Views/GoalsTab/` — savings goals +- `Views/ProfileTab/` — user profile, settings, achievements +- `Views/Auth/` — login, signup +- `Views/Onboarding/` — first-run flow +- `Views/Components/` — reusable building blocks (cards, buttons, etc.) + +ViewModels mirror the same structure under `ViewModels/`. + +## Definition of Done + +1. Build passes: `xcodebuild build -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'` +2. Preview renders correctly in light + dark mode. +3. New strings added to `Strings.swift`. +4. No hex color literals, no magic spacing numbers. +5. Hand off to `qa-tester` if behavior is non-trivial (anything beyond pure layout). + +When in doubt, read `.claude/knowledge/architecture.md` and `.claude/knowledge/localization.md` before editing. diff --git a/.claude/commands/orch.md b/.claude/commands/orch.md new file mode 100644 index 0000000..8cb063a --- /dev/null +++ b/.claude/commands/orch.md @@ -0,0 +1,14 @@ +--- +description: Start a routed task — orchestrator classifies and dispatches to specialists +--- + +You are now operating as the orchestrator. Read `.claude/protocols/orchestrator.md` and follow it to handle the user's request below. + +Key reminders: +- One PR at a time, against `dev` branch +- Match the user's language (Spanish/English) +- Present options with trade-offs for non-trivial calls — do not decide silently +- No auto-merge +- Sequential specialist dispatch only + +User request: $ARGUMENTS diff --git a/.claude/knowledge/architecture.md b/.claude/knowledge/architecture.md new file mode 100644 index 0000000..2f6d39d --- /dev/null +++ b/.claude/knowledge/architecture.md @@ -0,0 +1,83 @@ +# Architecture + +How Savely is organized. Specialists pull this when they need to understand a layer they don't own. + +## High-level flow + +``` +SavelyApp (@main) + └── AppViewModel ← single source of truth: auth state, user profile, dark mode, network + └── ContentView + ├── .loading → splash + ├── .loggedOut → LoginView / SignUpView + ├── .onboarding → OnboardingView + └── .main → MainNavigationView (TabView) + ├── DashboardTab + ├── ExpensesTab + ├── IncomesTab + ├── GoalsTab + └── ProfileTab +``` + +`AppViewModel` is injected as `@EnvironmentObject` throughout. The `AppState` enum drives top-level routing. + +## Layers + +| Layer | Path | Role | Owns external I/O? | +|---|---|---|---| +| Views | `Savely/Views/` | Pure SwiftUI, no business logic | No | +| ViewModels | `Savely/ViewModels/` | `@MainActor`, `@Observable`. Compose data, expose state | No (delegates to managers) | +| Models | `Savely/Models/` | `@Model` (SwiftData) + DTOs (Codable, Firestore, OpenAI) | No | +| Managers | `Savely/Managers/` | Singletons that wrap external SDKs | Yes | +| Extensions | `Savely/Extensions/` | `Extension.swift` — split impls | Yes | +| Utilities | `Savely/Utilities/` | OpenAI client, OCR, helpers | Yes (only OpenAI client + OCR) | +| Resources | `Savely/Resources/` | Strings, colors, sizing constants | No | + +## The View / ViewModel contract + +- View binds to a view model via `@State` (owned by the parent that creates it) or `@Bindable` (if the view model is passed down). +- View model is `@MainActor` and `@Observable`. +- View model receives services via initializer (don't reach into `XYZManager.shared` from inside the view). +- View model never imports SwiftUI types beyond `Color`, `Image`, `LocalizedStringKey`. No `View` conformances. + +## The ViewModel / Manager contract + +- Manager methods are `async throws`. +- Manager errors are typed enums. +- View model wraps manager calls in `Task` and translates errors to user-facing `Strings.swift` keys. + +## SwiftData container + +Configured in `Savely/SavelyApp.swift`. Models registered: `ExpenseModel`, `IncomeModel`, `GoalModel`, `TipModel`. `DBUserModel` is the Firestore DTO and is **not** a SwiftData `@Model`. + +Migrations: when a `@Model` field changes, register a migration plan in `SavelyApp.swift`. Coordinate via `data-model-specialist`. + +## Firestore + +Authoritative source for the user profile (`DBUserModel`). Local SwiftData is the source of truth for transactions (expenses, incomes, goals) — Firestore is not used for them today. If that changes, document in `firebase.md`. + +## Manager / Extension split + +Each manager has a small core file and one or more extension files: + +``` +Managers/AuthenticationManager.swift ← class declaration, shared state +Extensions/AuthenticationManagerExtension.swift ← email/password flow, Apple flow, etc. +``` + +The split keeps each file under ~200 lines. When adding a new method, add it to the extension that matches its concern, or create a new extension if it's a new concern. + +## Routing decision tree (for the orchestrator) + +``` +Touches a .swift file under Views/ or ViewModels/ → swiftui-feature-specialist +Touches a .swift file under Models/ → data-model-specialist +Touches Managers/, Extensions/, OpenAIClient, + Notifications, OCRUtilities, SignInWithApple → services-specialist +Touches .github/, project.pbxproj, entitlements, + schemes, .swiftlint.yml, Package.resolved → build-ci-specialist +Touches SavelyTests/ or SavelyUITests/ → qa-tester +Touches .gitignore, .github/templates, branches, + commits, PRs → git-workflow-specialist +Multiple of the above → split into multiple PRs +``` diff --git a/.claude/knowledge/common-rules.md b/.claude/knowledge/common-rules.md new file mode 100644 index 0000000..77beb21 --- /dev/null +++ b/.claude/knowledge/common-rules.md @@ -0,0 +1,102 @@ +# Common rules — the shared rulebook + +Every specialist follows these. Listed once here so the agent files don't repeat them. + +## Definition of Done (every PR) + +1. `xcodebuild build -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'` succeeds. +2. `xcodebuild test -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'` passes. +3. `swiftlint --strict` exits 0 (soft-skip until SwiftLint is installed). +4. No hardcoded user-facing strings — everything routed through `Resources/Strings.swift`. +5. No secrets staged. See list below. +6. `CLAUDE.md` updated if a new invariant is introduced. +7. `.claude/knowledge/gotchas.yaml` appended if a workaround was needed. +8. Branch named with approved prefix. +9. Commits follow Conventional Commits. +10. PR opened against `dev` (or `main` for `release/*` only). CI green. **No auto-merge.** + +## Prohibited git operations + +Never, regardless of who asks: + +- `git push --force` to `main` or `dev` +- `git commit --no-verify` — fix the hook failure instead +- `git commit --amend` on already-pushed commits on shared branches +- `git rebase -i main` or `dev` after push +- `git branch -D ` if it has unmerged work and the user hasn't confirmed +- Deleting `Savely.xcodeproj/project.pbxproj` + +If a hook fails, the commit didn't happen. Fix the issue, re-stage, create a **new** commit. Don't `--amend` after a hook failure — that would modify the previous commit. + +## Secrets — never commit + +| Path / pattern | What it is | +|---|---| +| `Savely/Config.plist` | OpenAI API key | +| `Savely/GoogleService-Info.plist` | Firebase config | +| `.env`, `.env.*` | Environment files | +| `*.mobileprovision`, `*.provisionprofile` | Provisioning profiles | +| `*.p12`, `*.cer`, `*.certSigningRequest` | Signing certs | +| `AuthKey_*.p8` | App Store Connect API keys | +| `*.xcresult` | Test result bundles (large, sometimes contain user data) | + +If you see any of these about to be staged: **stop and tell the user.** + +## Branch naming + +| Prefix | Targets | Use for | +|---|---|---| +| `feature/` | `dev` | New user-facing capability | +| `fix/` | `dev` | Bug fix | +| `refactor/` | `dev` | Restructure, no behavior change | +| `chore/` | `dev` | Tooling, deps, docs | +| `ci/` | `dev` | CI/CD config | +| `release/` | `main` | Release prep | +| `hotfix/` | `main` | Critical production fix | + +Slug: kebab-case, ≤40 chars, descriptive. + +## Commit messages — Conventional Commits + +``` +(): + + + + +``` + +Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `ci`, `build`, `test`, `style`, `perf`. + +Enforced by the `commit-msg` git hook (`.githooks/commit-msg`). + +## When you discover a new invariant + +Append to `.claude/knowledge/gotchas.yaml`: + +```yaml +- id: + summary: + why: + how-to-apply: +``` + +Then mention it in the PR description. + +## Language + +Match the user's language. If they switch from English to Spanish mid-conversation, switch with them. + +## Surfacing options vs. deciding silently + +For non-trivial choices (architecture, dependencies, naming patterns, new abstractions), present 2 options with trade-offs and let the user pick. Don't decide silently. The user is solo and is using this project to learn — explanations are part of the value. + +Trivial decisions (variable names, where exactly to put a small helper) — just decide and move on. + +## Auto-merge + +**Never.** The user reviews and merges every PR manually. + +## Long-running commands + +`xcodebuild build`, `xcodebuild test`, `swift package resolve` — ask before running. They're slow and noisy in the terminal. diff --git a/.claude/knowledge/firebase.md b/.claude/knowledge/firebase.md new file mode 100644 index 0000000..92a0e05 --- /dev/null +++ b/.claude/knowledge/firebase.md @@ -0,0 +1,107 @@ +# Firebase + +How Savely uses Firebase, what's stored where, and the rules for changing it. + +## What we use + +| Product | Purpose | +|---|---| +| Firebase Auth | Email/password + Sign in with Apple | +| Firestore | User profile (only) | + +We do **not** use Firebase Analytics, Crashlytics, Remote Config, Cloud Functions, Cloud Messaging, or Storage today. Adding any of these is a non-trivial decision — surface as an option, don't pull them in silently. + +## Configuration + +`GoogleService-Info.plist` lives in `Savely/` and is **gitignored**. Firebase reads it automatically at app launch. + +If you ever need to regenerate it: Firebase console → Project settings → Your apps → iOS → download `GoogleService-Info.plist` → drop into `Savely/` (don't commit). + +## Auth + +`AuthenticationManager.shared` owns auth state. Public surface: + +```swift +final class AuthenticationManager { + static let shared: AuthenticationManager + var currentUser: AuthDataResultModel? { get } + var authStateStream: AsyncStream { get } + + func signIn(email: String, password: String) async throws -> AuthDataResultModel + func signUp(email: String, password: String) async throws -> AuthDataResultModel + func signInWithApple() async throws -> AuthDataResultModel + func signOut() throws + func deleteAccount() async throws +} +``` + +`AuthDataResultModel` (in `Models/`) wraps `FirebaseAuth.User` — never expose the raw Firebase type to views. + +## Firestore: user profile + +The single Firestore collection in use today is `users/{uid}`, storing one document per user. + +### `users/{uid}` document shape + +``` +users/{uid} +├── id: String (== uid) +├── email: String +├── name: String? +├── photoURL: String? +├── isPremium: Bool +├── currency: String (e.g. "MXN", "USD") +├── createdAt: Timestamp +└── updatedAt: Timestamp +``` + +The Swift mirror is `DBUserModel` in `Models/DBUserModel.swift`. CRUD goes through `UserManager.shared`: + +```swift +final class UserManager { + static let shared: UserManager + func createUser(_ user: DBUserModel) async throws + func fetchUser(userId: String) async throws -> DBUserModel + func updateUser(_ user: DBUserModel) async throws + func deleteUser(userId: String) async throws +} +``` + +When the document shape changes: +1. Update `DBUserModel`. +2. Update encode/decode logic in `UserManager` and its extension. +3. Update this file. +4. If the change is backwards-incompatible, write a migration path (read old → write new) in `UserManager`. Test on a real document. + +## Offline behavior + +Firestore caches by default — reads work offline against the local cache, writes queue and sync on reconnect. **Don't disable persistence.** + +Network state is observed by `AppViewModel`. When offline, the UI should surface a banner via `Strings.swift` (currently TBD — open work). + +## What's NOT in Firestore (and shouldn't be) + +- **Expenses, incomes, goals, tips** — these live in SwiftData locally. There is no cloud sync today. +- **OpenAI API key** — lives in `Config.plist`, not Firestore. +- **App settings** (dark mode, onboarding flag) — UserDefaults. + +If you want to add cloud sync for transactions, that's a major architecture change — surface as an option, document the trade-offs (conflict resolution, write costs, offline reconciliation), and get explicit approval. + +## Security rules + +Firestore security rules live in the Firebase console (not in this repo). The current rule of thumb is: + +``` +match /users/{uid} { + allow read, write: if request.auth != null && request.auth.uid == uid; +} +``` + +When a new collection or field is added, update the rules in the console **before** the client code ships. + +## Common gotchas + +- **Timestamps:** Firestore returns `Timestamp`, not `Date`. Convert in the DTO via `dateValue()`. +- **Optional fields:** Firestore omits `nil` fields entirely. When decoding, use `Optional` Swift-side. +- **Document IDs:** the `id` field stored in the document must equal the document's path-level ID (`uid`). They drift when someone forgets, and it's painful to fix. +- **`@DocumentID` property wrapper** is from `FirebaseFirestoreSwift` — we don't use it; we encode manually. diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml new file mode 100644 index 0000000..e23e87d --- /dev/null +++ b/.claude/knowledge/gotchas.yaml @@ -0,0 +1,34 @@ +# gotchas.yaml — append-only log of project invariants +# +# Schema: +# - id: kebab-case unique identifier +# summary: one-line statement of the rule +# why: the underlying reason (what breaks if you ignore this) +# how-to-apply: when/where this rule kicks in +# +# Discovered during the initial scaffold (2026-04-24): + +- id: only-savely-scheme-is-shared + summary: xcodebuild must use -scheme Savely; SavelyTests/SavelyUITests schemes are not shared. + why: Only Savely.xcscheme exists in xcshareddata. -scheme SavelyTests fails on a fresh checkout. The Savely scheme's Test action runs both bundles together. + how-to-apply: Any time you write or copy an xcodebuild command — for CI, for local docs, for agent prompts. + +- id: deployment-target-floor-ios-26 + summary: iOS 26.0 is the minimum deployment target across all targets and the project default. + why: User explicitly chose this to use modern APIs (@Observable, NavigationStack, Swift Testing, Observation framework). All six IPHONEOS_DEPLOYMENT_TARGET values in project.pbxproj are now 26.0; UI test targets inherit it from the project default. + how-to-apply: Free to use iOS 26 APIs. Don't add #available(iOS 17, *) checks. Don't fall back to ObservableObject for new view models. If you ever bump the project default, bump every per-target override too — mismatches break @testable import with "minimum deployment target" errors. + +- id: secrets-are-gitignored + summary: Savely/Config.plist and Savely/GoogleService-Info.plist contain secrets and must never be committed. + why: They hold the OpenAI API key and Firebase config respectively. Leaking them on a public repo means key rotation + potential abuse charges. + how-to-apply: Before any commit, grep the staged diff for these paths. If present, stop and tell the user. + +- id: ui-tests-skipped-on-ci + summary: SavelyUITests bundle is excluded from CI execution via -skip-testing — compilation only, no run. + why: The Xcode UI test template ships SavelyUITestsLaunchTests with XCTApplicationLaunchMetric which runs the app launch ~8 times measuring performance, taking ~5–10 min and flaking when one iteration exceeds the std-dev threshold. The template tests assert nothing meaningful — they only measure launch time, which is noisy on shared runners. Burning that many minutes for no signal is wasteful. + how-to-apply: Leave -skip-testing:SavelyUITests in .github/workflows/ci.yml's Test step until qa-tester writes real UI tests that exercise actual user paths. When real UI tests exist, drop the flag (or narrow it to skip only the template's launch-perf tests). Build step still compiles the bundle, so breaking changes to UI test code are still caught. + +- id: ci-needs-placeholder-config-plist + summary: CI must generate a placeholder Config.plist before xcodebuild because the Xcode project lists it as a required build input. + why: Config.plist is gitignored, so a fresh checkout (incl. CI runners) does not have it. xcodebuild fails with "Build input file cannot be found" before compilation even starts. The OpenAI key itself is not needed — CI never makes the network call — only the file with the right shape. + how-to-apply: In .github/workflows/ci.yml, write a minimal plist with the OPENAI_API_KEY field before the Resolve SPM / Build steps. If GoogleService-Info.plist later stops being tracked, apply the same pattern (and use a real Firebase config from a GitHub secret if you ever need a working Firebase build on CI). diff --git a/.claude/knowledge/localization.md b/.claude/knowledge/localization.md new file mode 100644 index 0000000..1bb29df --- /dev/null +++ b/.claude/knowledge/localization.md @@ -0,0 +1,74 @@ +# Localization + +The hard rule: **no hardcoded user-facing strings in views.** Everything goes through `Resources/Strings.swift` and is registered in `Localizable.xcstrings`. + +## How it works + +`Strings.swift` exposes typed constants that wrap `NSLocalizedString` (or `String(localized:)` on iOS 15+): + +```swift +enum L10n { + enum Goals { + static let title = String(localized: "goals.title", defaultValue: "Goals") + static let addGoal = String(localized: "goals.addGoal", defaultValue: "Add Goal") + static func progress(_ percent: Int) -> String { + String(localized: "goals.progress", defaultValue: "\(percent)% complete") + } + } +} +``` + +`Localizable.xcstrings` is Xcode's string catalog format (Xcode 15+). It auto-discovers keys when you reference them via `String(localized:)`. + +## Adding a new string + +1. Pick a dotted key: `.` or `..`. Examples: `expenses.empty.title`, `auth.login.error.invalidCredentials`. +2. Add a constant in the appropriate `L10n.` enum in `Strings.swift`. +3. Use it in the view: `Text(L10n.Expenses.title)`. +4. Open `Localizable.xcstrings` in Xcode — the new key appears under the default language. Add translations for other locales. + +## Pluralization + +Use `String(localized:)` with a stringsdict-style entry in the catalog: + +```swift +static func itemCount(_ n: Int) -> String { + String(localized: "items.count", + defaultValue: "\(n) items") +} +``` + +Then in the catalog, set the variation by `%lld` count → singular `1 item` / plural `%lld items`. + +## Don't + +- ❌ `Text("Goals")` — hardcoded literal +- ❌ `Text("\(count) items")` — manual pluralization +- ❌ String concatenation: `Text("Hello, " + name)` — use `Text("Hello, \(name)")` and localize the whole template +- ❌ Capitalize/lowercase the result of a localized string — different languages have different casing rules + +## Date and number formatting + +Don't use `Strings.swift` for these — use `Date.FormatStyle` and `Decimal.FormatStyle`: + +```swift +expense.date.formatted(date: .abbreviated, time: .omitted) +amount.formatted(.currency(code: "MXN")) +``` + +These respect the user's locale automatically. + +## Accessibility labels + +Accessibility labels are also user-facing strings. Same rule — go through `L10n`: + +```swift +Image(systemName: "trash") + .accessibilityLabel(L10n.Common.delete) +``` + +## Catalog hygiene + +- Keep keys grouped by feature in `Strings.swift` so they're easy to find. +- When you delete a feature, delete its keys from both `Strings.swift` and the catalog. +- Don't ship strings with `defaultValue` only — make sure every supported locale has a translation before merging a release. diff --git a/.claude/knowledge/signing-and-ci.md b/.claude/knowledge/signing-and-ci.md new file mode 100644 index 0000000..a98d60e --- /dev/null +++ b/.claude/knowledge/signing-and-ci.md @@ -0,0 +1,115 @@ +# Signing & CI/CD + +Everything about signing, schemes, and the GitHub Actions pipeline. + +## Signing today + +- **Style:** Automatic +- **Team:** `ZHLD96SP29` +- **Bundle IDs:** + - App: `IvanLB.Savely` + - Unit tests: `IvanLB.SavelyTests` + - UI tests: `IvanLB.SavelyUITests` +- **Capabilities:** Sign in with Apple (configured in `Savely/Savely.entitlements`) + +Don't switch to manual signing. Don't introduce `.xcconfig` files without explicit approval — they're a non-trivial refactor. + +## Schemes + +Only `Savely.xcscheme` is shared (under `Savely.xcodeproj/xcshareddata/xcschemes/`). Its Test action runs both `SavelyTests` and `SavelyUITests` together. + +**Consequence:** `xcodebuild -scheme SavelyTests` fails on a fresh checkout. Always use `-scheme Savely`. + +If you want them runnable independently, share the test schemes via Xcode → Manage Schemes → check "Shared" for `SavelyTests` and `SavelyUITests`. Then commit the new files in `xcshareddata/xcschemes/`. + +## Build & test commands (canonical) + +```bash +# Build +xcodebuild build \ + -project Savely.xcodeproj \ + -scheme Savely \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + +# Test (both unit + UI via the shared scheme) +xcodebuild test \ + -project Savely.xcodeproj \ + -scheme Savely \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + -resultBundlePath build/TestResults.xcresult +``` + +## The CI pipeline + +`.github/workflows/ci.yml` runs on: +- every PR (any base branch) +- every push to `dev` and `main` + +Steps: + +1. **Checkout** — `actions/checkout@v4`. +2. **Select Xcode** — pins to a specific Xcode version. Without this, GitHub silently bumps Xcode and your builds break "for no reason." +3. **Cache SPM** — caches `~/Library/Developer/Xcode/DerivedData/.../SourcePackages` keyed on `Package.resolved`'s hash. Saves ~60–90s per run when nothing changed. +4. **SwiftLint** — `swiftlint --strict` exits non-zero on any violation. This is the cheapest gate, runs first. +5. **Build** — `xcodebuild build` for the `Savely` scheme. Fails fast if compilation breaks. +6. **Test** — `xcodebuild test`. Emits `.xcresult` bundle. +7. **Upload artifact** — the `.xcresult` is uploaded so you can download it and open in Xcode when CI fails. Without this you're stuck reading 5,000 lines of CI logs. + +## Cost reality + +GitHub Actions free tier: 2,000 minutes/month for private repos, but **macOS counts at 10×** → effectively 200 macOS minutes/month. + +A typical iOS run is ~6–10 min on `macos-15`, billed as 60–100 minutes. Solo dev with ~5 PRs/week → ~1,500–2,500 billed minutes/month. + +When you hit the cap: +- **Pay-as-you-go**: ~$0.08 per macOS minute. +- **Self-hosted runner** on your own Mac: free, but you have to maintain it. +- **Skip CI on draft PRs**: saves minutes during WIP. Configure via `if: github.event.pull_request.draft == false`. + +## Branch protection (set up via GitHub UI) + +After pushing the workflow at least once and creating the `dev` branch: + +1. Go to **Settings → Branches → Branch protection rules → Add rule**. +2. Branch name pattern: `main`. Settings: + - ✅ Require a pull request before merging + - ✅ Require status checks to pass before merging → select `ci` (the workflow's job name) + - ✅ Require branches to be up to date before merging + - ✅ Require linear history + - ✅ Do not allow bypassing the above settings +3. Repeat for `dev` with the same settings. +4. Optionally enable: **Settings → General → Pull Requests → Automatically delete head branches**. + +## Secrets management + +Repo secrets live at **Settings → Secrets and variables → Actions**. + +Today, the CI workflow needs **no secrets** (it doesn't sign or upload). When `release.yml` is added later, it will need: +- `APP_STORE_CONNECT_API_KEY` — the JSON contents of an App Store Connect API key +- `APP_STORE_CONNECT_API_KEY_ID` +- `APP_STORE_CONNECT_API_ISSUER_ID` +- `MATCH_PASSWORD` — encrypts the signing certs repo (if using fastlane match) + +**Never** echo a secret in a workflow. **Never** `set -x` in a step that touches one. + +## Deployment target reality (open task) + +Current state: +- Project default: `IPHONEOS_DEPLOYMENT_TARGET = 17.0` +- App target: `26.0` +- Test targets: `17.5` + +User decision: **iOS 26 is the floor everywhere.** First job for `build-ci-specialist` is to align all three to `26.0` in one PR (`build: align deployment target to iOS 26`). + +## Releases (future) + +Not implemented yet. When ready, the flow will be: + +1. Cut a `release/` branch off `dev`. +2. Bump `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.pbxproj`. +3. Generate changelog from `git log dev..main --oneline`. +4. PR `release/` → `main`. CI runs. +5. Merge. Tag `v` on `main`. +6. (Future) `release.yml` workflow uploads to TestFlight via fastlane. +7. Back-merge `main` → `dev` so `dev` has the version bump. diff --git a/.claude/knowledge/testing.md b/.claude/knowledge/testing.md new file mode 100644 index 0000000..29cedb2 --- /dev/null +++ b/.claude/knowledge/testing.md @@ -0,0 +1,132 @@ +# Testing + +How tests are organized, how to run them, and the migration plan to Swift Testing. + +## Today's state + +- `SavelyTests/SavelyTests.swift` — XCTest skeleton, no real coverage +- `SavelyUITests/SavelyUITests.swift` — XCTest skeleton +- `SavelyUITests/SavelyUITestsLaunchTests.swift` — XCTest launch test + +The test schemes (`SavelyTests`, `SavelyUITests`) are **not shared**. Only `Savely.xcscheme` is shared, and its Test action includes both bundles. + +## Running tests + +```bash +xcodebuild test \ + -project Savely.xcodeproj \ + -scheme Savely \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + -resultBundlePath build/TestResults.xcresult +``` + +To open the result bundle locally: +```bash +open build/TestResults.xcresult +``` + +## Swift Testing migration + +**New tests use Swift Testing (`@Test`)**, not XCTest. Swift Testing landed in Xcode 16 and is the modern API. + +```swift +import Testing +@testable import Savely + +@Suite("Goal progress") +struct GoalProgressTests { + @Test("clamps at 100% when saved exceeds target") + func clampsAtOneHundred() async throws { + let goal = GoalModel(target: 100, saved: 150) + #expect(goal.progress == 1.0) + } + + @Test("returns 0 when target is 0") + func zeroTarget() async throws { + let goal = GoalModel(target: 0, saved: 50) + #expect(goal.progress == 0) + } +} +``` + +Existing XCTest files stay XCTest until they're rewritten — don't mix `@Test` and `XCTestCase` in the same file. + +UI tests stay on XCTest for now — Swift Testing's UI-test support is less mature. + +## Test data: in-memory SwiftData + +Don't mock `ModelContext`. Use an in-memory container: + +```swift +@MainActor +@Test func goalCreationPersists() async throws { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer( + for: GoalModel.self, + configurations: config + ) + let context = container.mainContext + + let goal = GoalModel(name: "Test", target: 1000, saved: 0) + context.insert(goal) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.count == 1) +} +``` + +## Firebase in tests + +Don't hit Firebase in unit tests. Two options: + +1. **Inject a protocol** — wrap the manager in a protocol, provide a fake in tests. +2. **Firebase Local Emulator Suite** — for integration tests, run `firebase emulators:start` and point the SDK at it. Mark these tests with a separate `@Suite("Integration")` and skip in CI by default. + +Today there are no integration tests. When adding them, document the setup in `firebase.md` and gate them behind an env var so they don't run in regular CI. + +## Migration tests (SwiftData) + +When a `@Model` schema changes, add a test: + +```swift +@Test func migratesGoalsFromV1ToV2() async throws { + // 1. Create a container at the V1 schema with sample data + // 2. Run the migration + // 3. Open at V2 schema and verify the data shape +} +``` + +Without this, you find out about migration bugs in production when users update. + +## What to test (and what not to) + +**Always test:** +- Pure functions and computed properties on models (`progress`, `formattedAmount`, etc.) +- View model state transitions (loading → loaded → error) +- Migration paths +- Bug fixes (write the test that reproduces the bug, then fix) + +**Don't test:** +- SwiftUI view layout (use Previews instead) +- Trivial getters/setters +- Third-party SDK behavior (Firebase, OpenAI internals) + +**UI test the critical paths only:** +- Launch → onboarding → main +- Login → main +- Add expense → see it in dashboard +- Add goal → see it in goals tab + +UI testing every screen is brittle and slow. Pick the user journeys that would be embarrassing to break. + +## CI and result bundles + +When CI fails, the `.xcresult` bundle is uploaded as an artifact. Steps to read it: + +1. Open the failed workflow run on GitHub +2. Scroll to "Artifacts" at the bottom +3. Download `TestResults.xcresult.zip` +4. Unzip and `open TestResults.xcresult` in Xcode + +The bundle has the full test report, console output, and any screenshots/recordings UI tests captured. diff --git a/.claude/protocols/orchestrator.md b/.claude/protocols/orchestrator.md new file mode 100644 index 0000000..c2600d0 --- /dev/null +++ b/.claude/protocols/orchestrator.md @@ -0,0 +1,137 @@ +# Orchestrator Protocol + +You are the orchestrator. You do **not** write code yourself except for trivial path moves and PR descriptions. Your job is: classify the request, plan one PR's worth of work, dispatch to the right specialist(s) in sequence, validate against the Definition of Done, and open the PR. + +Solo-developer workflow rules: +- **One PR at a time.** If the request smells like two unrelated changes, ask the user to split it. +- **Match the user's language.** If the user wrote in Spanish, respond in Spanish. +- **Options over decisions** for non-trivial calls. Present 2 options + trade-offs; do not decide silently. +- **No auto-merge.** Hand off to the user for the merge. +- **Sequential dispatch only.** No parallel specialists for now — keeps the diff coherent. + +--- + +## Step 1 — Classify + +Pick exactly one class. If the request spans multiple classes, the *primary* class wins; surface the rest in the plan. + +| Class | Trigger | Primary specialist | +|---|---|---| +| `feature` | New user-facing capability | `swiftui-feature-specialist` | +| `bug-fix` | Regression / incorrect behavior | (orchestrator triages first → routes by layer) | +| `refactor` | Restructure, no behavior change | (route by layer) | +| `ui-redesign` | Visual / layout change to existing screens | `swiftui-feature-specialist` | +| `data-migration` | New SwiftData field, schema change, Firestore doc shape | `data-model-specialist` | +| `services-change` | Auth, networking, OpenAI, notifications, camera | `services-specialist` | +| `build-config` | Bundle ID, entitlements, capabilities, deployment target | `build-ci-specialist` | +| `dependency-bump` | SPM package upgrade | `build-ci-specialist` | +| `ci-config` | `.github/workflows/**`, lint config | `build-ci-specialist` | +| `release` | Version bump, tag, TestFlight prep | `build-ci-specialist` → `git-workflow-specialist` | +| `docs` / `chore` | README, CLAUDE.md, knowledge files, gitignore | `git-workflow-specialist` | + +Bug-fix triage rule: read the failing code path first, identify the layer it lives in, then route to that layer's specialist. Do **not** dispatch a generic "bug-triage" agent — that's just an extra hop. + +--- + +## Step 2 — Plan (output in chat, get user buy-in) + +Produce a short plan in this shape: + +``` +Class: +Branch: / +Files I expect to touch: +Specialists: +Risks / unknowns: +Open question(s) for user: +Definition of Done: see CLAUDE.md +``` + +If you have a non-trivial choice (e.g. "add this as a new screen vs. extend the existing one", "store this in SwiftData vs. UserDefaults"), present 2 options and ask. Do not proceed until the user picks. + +--- + +## Step 3 — Dispatch + +Spawn specialists one at a time. Each specialist gets: +- The plan above +- A specific scope (which files, which behavior) +- A reminder of the rules in `.claude/knowledge/common-rules.md` +- A reminder of any relevant knowledge file (`architecture.md`, `localization.md`, `firebase.md`, etc.) + +Do **not** let two specialists touch the same file in one PR. If that's needed, the PR is too big — split it. + +After each specialist returns, read the diff. Do not trust the agent's summary blindly — verify the diff matches the scope. + +--- + +## Step 4 — Validate (Definition of Done) + +Before opening the PR, verify each item: + +1. ✅ Build passes — run `xcodebuild build -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'`. Ask the user before running, since this is a long command. +2. ✅ Tests pass — run `xcodebuild test -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'`. Ask first. +3. ✅ SwiftLint clean — `swiftlint --strict` (soft-skip if not installed). +4. ✅ No hardcoded user-facing strings introduced. Grep the diff for string literals in `.swift` view files. +5. ✅ No secrets staged. Grep the diff for `Config.plist`, `GoogleService-Info.plist`, `.env`, API keys. +6. ✅ `CLAUDE.md` updated if a new invariant emerged. +7. ✅ `gotchas.yaml` appended if a workaround was needed (orchestrator writes this, not the specialist). +8. ✅ Branch named with approved prefix. +9. ✅ Commits follow Conventional Commits. + +If any check fails, hand back to the responsible specialist with the specific failure. Do **not** patch it yourself unless it's a trivial typo. + +--- + +## Step 5 — Open the PR + +- **Always against `dev`** (except `release/*` branches, which target `main`). +- Title: Conventional Commit format, ≤70 chars. Example: `feat(goals): add edit-goal flow`. +- Body template: + +```markdown +## Summary +- +- + +## Why + + +## Test plan +- [ ] xcodebuild build (Savely scheme, iPhone 16 Pro) +- [ ] xcodebuild test (Savely scheme, iPhone 16 Pro) +- [ ] Manual: + +## Risks + + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +``` + +After opening, return the PR URL. **Do not merge.** The user merges manually. + +--- + +## When you discover a new invariant + +If during the work you find a constraint that wasn't documented (e.g. "SwiftData container must be initialized before any `@Query` view loads"), append it to `.claude/knowledge/gotchas.yaml`. One entry, schema: + +```yaml +- id: + summary: + why: + how-to-apply: +``` + +Then mention it in the PR description so the user knows you added it. + +--- + +## Anti-patterns to avoid + +- Spawning multiple specialists in parallel "to save time" — they'll collide on shared files and the diff will be a mess. +- Patching a specialist's output yourself — if the specialist got it wrong, hand it back with the specific failure. +- Skipping the plan step for "small" changes — even a one-line fix benefits from naming the branch and PR title up front. +- Running `xcodebuild` without asking — it's slow and noisy in the user's terminal. +- Editing files outside a specialist's owned paths — that's the specialist's job. +- Force-pushing or amending to "clean up history" — never on `main`/`dev`, never on shared branches. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..3595961 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Commit-msg hook: enforce Conventional Commits format. +# +# Format: +# (): +# +# Allowed types: feat, fix, refactor, chore, docs, ci, build, test, style, perf +# +# Examples: +# feat(goals): add edit-goal flow +# fix(expenses): clamp negative amounts to zero +# chore(deps): bump firebase-ios-sdk to 11.6.0 +# +# Activated by running scripts/install-hooks.sh once after cloning. + +set -euo pipefail + +commit_msg_file="$1" +first_line=$(head -n 1 "$commit_msg_file") + +# Allow merge / revert / fixup commits to bypass the format check +if [[ "$first_line" =~ ^(Merge|Revert|fixup!|squash!) ]]; then + exit 0 +fi + +# Conventional Commits regex: ()?: +pattern='^(feat|fix|refactor|chore|docs|ci|build|test|style|perf)(\([a-z0-9-]+\))?!?: .{1,72}$' + +if [[ ! "$first_line" =~ $pattern ]]; then + echo "" + echo "❌ Commit message does not follow Conventional Commits." + echo "" + echo " Got: $first_line" + echo "" + echo " Expected: (): " + echo "" + echo " Allowed types:" + echo " feat — new user-facing feature" + echo " fix — bug fix" + echo " refactor — restructure, no behavior change" + echo " chore — tooling, deps, misc" + echo " docs — documentation only" + echo " ci — CI/CD config" + echo " build — build system, project settings" + echo " test — adding or updating tests" + echo " style — formatting, whitespace" + echo " perf — performance improvement" + echo "" + echo " Examples:" + echo " feat(goals): add edit-goal flow" + echo " fix(expenses): clamp negative amounts to zero" + echo " ci: cache SPM packages in PR workflow" + echo "" + exit 1 +fi + +# Subject must not end with a period +if [[ "$first_line" =~ \.$ ]]; then + echo "❌ Commit subject must not end with a period." + exit 1 +fi + +# Subject after `:` should start lowercase (imperative mood usually does) +subject=$(echo "$first_line" | sed -E 's/^[a-z]+(\([a-z0-9-]+\))?!?: //') +if [[ "$subject" =~ ^[A-Z] ]]; then + echo "⚠️ Subject starts with a capital letter; Conventional Commits prefers lowercase imperative." + echo " Got: $subject" + # Warn but don't block — capitalized proper nouns are sometimes legit +fi + +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..58c7131 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Pre-commit hook: run SwiftLint on staged Swift files. +# Saves CI minutes by catching lint errors before they reach GitHub. +# +# Activated by running scripts/install-hooks.sh once after cloning. + +set -euo pipefail + +# Skip if SwiftLint isn't installed — it's optional locally. +if ! command -v swiftlint >/dev/null 2>&1; then + echo "ℹ️ SwiftLint not installed — skipping. Install with: brew install swiftlint" + exit 0 +fi + +# Only lint staged .swift files +staged_swift_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.swift$' || true) + +if [[ -z "$staged_swift_files" ]]; then + exit 0 +fi + +echo "🔍 Running SwiftLint on staged files…" + +# Lint only the staged files (faster than full project) +echo "$staged_swift_files" | while read -r file; do + if [[ -f "$file" ]]; then + swiftlint lint --quiet --strict --use-script-input-files <<< "$file" || { + echo "" + echo "❌ SwiftLint failed. Fix the issues above, then re-stage and commit." + echo " To bypass (NOT recommended), use: git commit --no-verify" + exit 1 + } + fi +done + +echo "✅ SwiftLint clean." diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..fe7c08b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something doesn't work as expected +labels: bug +--- + +## What happened + + +## Steps to reproduce +1. ... +2. ... +3. ... + +## Expected behavior + + +## Actual behavior + + +## Environment +- Device: +- iOS version: +- App version: +- Branch / commit: + +## Logs / screenshots + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..15474a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Bug report + url: https://github.com/Ivan-LB/Savely/issues/new?template=bug_report.md + about: Something isn't working + - name: Feature request + url: https://github.com/Ivan-LB/Savely/issues/new?template=feature_request.md + about: Propose a new capability diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d96673c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Propose a new capability +labels: enhancement +--- + +## What's the feature + + +## Why + + +## Proposed approach + + +## Alternatives considered + + +## Risks / open questions + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d47db08 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Summary +- +- + +## Why + + +## Test plan +- [ ] `xcodebuild build -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'` +- [ ] `xcodebuild test -scheme Savely -destination 'platform=iOS Simulator,name=iPhone 16 Pro'` +- [ ] Manual: + +## Risks + + +## Checklist +- [ ] Branch named with approved prefix (`feature/`, `fix/`, `refactor/`, `chore/`, `ci/`, `release/`, `hotfix/`) +- [ ] Commits follow Conventional Commits +- [ ] No hardcoded user-facing strings (all routed through `Strings.swift`) +- [ ] No secrets staged (`Config.plist`, `GoogleService-Info.plist`, `.env*`) +- [ ] `CLAUDE.md` / knowledge files updated if a new invariant emerged +- [ ] Tests added or updated for behavior changes + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e3930aa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +version: 2 + +updates: + # Swift Package Manager dependencies + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Mexico_City" + open-pull-requests-limit: 5 + target-branch: "dev" + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" + # Group patch + minor bumps so we don't drown in PRs. + # Major bumps stay as individual PRs (potential breaking changes). + groups: + patch-and-minor: + update-types: + - "patch" + - "minor" + + # GitHub Actions versions in workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + target-branch: "dev" + commit-message: + prefix: "ci(deps)" + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1dde2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,129 @@ +name: ci + +# Runs on every PR (any base) and on every push to dev/main. +# Skips draft PRs to save macOS minutes. + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + +# Cancel old runs if a new commit lands on the same branch. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & test + runs-on: macos-15 + if: github.event.pull_request.draft == false || github.event_name == 'push' + + env: + # Pin Xcode so build behavior is reproducible. + # Bump when you upgrade local Xcode and want CI to follow. + XCODE_VERSION: "26.0" + SCHEME: "Savely" + DESTINATION: "platform=iOS Simulator,name=iPhone 16 Pro" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode ${{ env.XCODE_VERSION }} + run: | + # If the requested version isn't installed on the runner, + # this will fail loudly — pick a version that's available. + # See: https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" || \ + sudo xcode-select -s "/Applications/Xcode.app" + xcodebuild -version + + - name: Cache SPM packages + uses: actions/cache@v4 + with: + path: | + ~/Library/Developer/Xcode/DerivedData/**/SourcePackages + ~/Library/Caches/org.swift.swiftpm + key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}- + + - name: Install SwiftLint + run: | + brew install swiftlint + swiftlint version + + - name: SwiftLint + # No --strict: warnings annotate inline but don't fail the build. + # Only error-severity violations (force_cast, force_try, custom errors) + # block the merge. The "ratchet" comment in .swiftlint.yml explains + # how to tighten this over time. + run: swiftlint lint --reporter github-actions-logging + + - name: Generate placeholder Config.plist + # Config.plist holds the OpenAI API key locally and is gitignored. + # The Xcode project lists it as a required build input, so xcodebuild + # fails on a fresh checkout if the file is absent — even though CI + # never actually calls the OpenAI API. Write a placeholder with the + # right shape so the Resources copy phase succeeds. + run: | + cat > Savely/Config.plist <<'PLIST' + + + + + OPENAI_API_KEY + ci-placeholder-not-a-real-key + + + PLIST + + - name: Resolve SPM packages + run: | + xcodebuild \ + -project Savely.xcodeproj \ + -scheme "$SCHEME" \ + -resolvePackageDependencies + + - name: Build + run: | + set -o pipefail + xcodebuild build \ + -project Savely.xcodeproj \ + -scheme "$SCHEME" \ + -configuration Debug \ + -destination "$DESTINATION" \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + + - name: Test + # Skip the SavelyUITests bundle on CI. The Xcode template ships + # SavelyUITestsLaunchTests with XCTApplicationLaunchMetric, which + # runs the app launch ~8 times measuring performance — that takes + # ~5–10 min and flakes on shared runners (one slow iteration blows + # the std-dev threshold and the whole bundle fails). The template + # tests cover no real behavior. + # + # Compilation of SavelyUITests is still verified by the Build step, + # so we still catch breakages. When qa-tester writes real UI tests, + # remove the -skip-testing flag. + run: | + set -o pipefail + xcodebuild test \ + -project Savely.xcodeproj \ + -scheme "$SCHEME" \ + -destination "$DESTINATION" \ + -skip-testing:SavelyUITests \ + -resultBundlePath build/TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + + - name: Upload xcresult on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TestResults-${{ github.run_id }} + path: build/TestResults.xcresult + retention-days: 7 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..77cea0e --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,104 @@ +# SwiftLint config for Savely +# Philosophy: catch real bugs, not bikeshed style. +# CI runs without --strict, so warnings annotate but don't fail the build. +# Only error-severity violations block merge. +# Strictness ramps up over time — see "ratchet" notes below. + +included: + - Savely + - SavelyTests + - SavelyUITests + +excluded: + - .build + - DerivedData + - build + - Savely/Preview Content + - Savely.xcodeproj + +# Rules disabled outright — pure style/formatting noise that doesn't catch bugs. +# Re-enable selectively after a dedicated cleanup PR if you want any of these. +disabled_rules: + # Length/complexity — SwiftUI naturally produces long files + - line_length + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - identifier_name + - todo + - trailing_whitespace + # Spacing/formatting — Xcode handles these on save + - comma # space before/after comma + - colon # space around colon + - vertical_whitespace # double blank lines + - trailing_newline # missing final newline + - opening_brace # `{ }` placement + - closing_brace + - statement_position # else/catch placement + - vertical_parameter_alignment # multi-line param alignment + - void_return # `-> Void` vs `-> ()` + # Style judgments that conflict with real-world SwiftUI patterns + - multiple_closures_with_trailing_closure # SwiftUI `Button { } label: { }` is fine + - unused_closure_parameter # noisy in gesture/onChange handlers + - static_over_final_class # XCTest UI launch tests use class func + - large_tuple # sometimes a 3-tuple is the right shape + - trailing_comma # Swift 6 makes trailing commas universal + - implicit_optional_initialization # `var x: String? = nil` is harmless + - legacy_objc_type # NSAttributedString etc are still mainstream + +# Opt-in rules — these find real bugs or measurable problems. +# Anything here that's flagged will be a *warning* by default; CI ignores it +# (no --strict). Bump severity to error individually below as the codebase is cleaned. +opt_in_rules: + # Performance bugs masquerading as idiomatic code + - empty_count # `arr.count == 0` is O(n) on lazy seqs — use isEmpty + - empty_string # `s == ""` — same idea + - first_where # `.filter { }.first` allocates the full array + - last_where # same for `.last` + - contains_over_filter_count # `.filter { }.count > 0` allocates — use contains(where:) + - contains_over_first_not_nil # `.first(where:) != nil` — same + - sorted_first_last # `.sorted().first` → `.min()` + - reduce_into # use reduce(into:) for mutable accumulators + # Correctness / typo catchers + - identical_operands # `a == a` is a typo + - empty_collection_literal # `[].first` is suspicious + - redundant_nil_coalescing # `x ?? nil` is pointless + - explicit_init # `.init()` when redundant + - toggle_bool # `bool = !bool` → bool.toggle() + - lower_acl_than_parent # `internal extension on private type` etc. + # Real-bug surface + - force_unwrapping # runtime crash — warning today, error after cleanup + - overridden_super_call # forgetting super.viewDidLoad() etc. + - prohibited_super_call + - weak_delegate # strong delegate = retain cycle + - nslocalizedstring_key # localization key must be a literal + +# Analyzer-only rules — run via `swiftlint analyze`, not `lint`. +# Listed here so SwiftLint stops warning that they're misplaced. +analyzer_rules: + - unused_declaration + - unused_import + +nesting: + type_level: 3 + function_level: 3 + +# Severity overrides — the ratchet +force_cast: + severity: error # error: explicit casts that fail are bugs +force_try: + severity: error # error: same +force_unwrapping: + severity: warning # warning today → bump to error after cleanup PR + +# Custom project rules +custom_rules: + no_hardcoded_strings_in_views: + name: "Hardcoded user-facing string in a View" + regex: 'Text\("[A-Z][^"\\$]+"\)' + match_kinds: + - string + message: "Use Strings.swift / L10n instead of a hardcoded literal." + severity: warning + included: ".*Views/.*\\.swift" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e86683 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# CLAUDE.md + +Front door for Claude Code sessions in this repo. **Read this file first**, then follow the link to the orchestrator protocol if you're starting non-trivial work. + +> If you're starting a task: invoke `/orch` (or read `.claude/protocols/orchestrator.md` directly). The orchestrator classifies the request and routes it to the right specialist. + +--- + +## What this is + +Savely is a personal finance iOS app (iOS 26+) built with SwiftUI + SwiftData + Firebase. Solo developer. No CI/CD yet — being added now. + +--- + +## Build & test commands + +The only shared scheme is `Savely`. Its Test action runs both `SavelyTests` and `SavelyUITests`. + +```bash +# Build +xcodebuild build \ + -project Savely.xcodeproj \ + -scheme Savely \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + +# Test (unit + UI together — they share the Savely scheme) +xcodebuild test \ + -project Savely.xcodeproj \ + -scheme Savely \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + -resultBundlePath build/TestResults.xcresult + +# Lint (after SwiftLint is installed: `brew install swiftlint`) +swiftlint --strict +``` + +> **Note:** `-scheme SavelyTests` and `-scheme SavelyUITests` will fail — those schemes aren't shared. Always use `-scheme Savely`. + +--- + +## Branch model + +| Branch | Role | Who pushes here | +|---|---|---| +| `main` | Production / released code | Releases only — merge from `dev` when shipping | +| `dev` | Integration branch | All feature/fix PRs target this | +| `feature/` | Short-lived feature branches | Off `dev`, PR back to `dev` | +| `fix/` | Short-lived bug fixes | Off `dev`, PR back to `dev` | +| `refactor/` | Structural changes, no behavior change | Off `dev`, PR back to `dev` | +| `chore/` | Tooling, deps, docs | Off `dev`, PR back to `dev` | +| `ci/` | CI/CD config changes | Off `dev`, PR back to `dev` | +| `release/` | Release prep (changelog, version bump) | Off `dev`, PR to `main` | + +**Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `ci:`, `build:`, `test:`. The `commit-msg` hook in `.githooks/` enforces this — run `scripts/install-hooks.sh` once after cloning. + +**Never** force-push `main` or `dev`. Never `--no-verify`. Never amend already-pushed commits. + +--- + +## Repository map + +``` +Savely/ +├── SavelyApp.swift # @main entry point — configures ModelContainer +├── ContentView.swift # AppState routing (loading / loggedOut / onboarding / main) +├── Models/ # SwiftData @Model classes + DTOs (Firestore, OpenAI) +├── ViewModels/ # @MainActor view models, organized per tab +├── Views/ # SwiftUI screens, organized per tab +├── Managers/ # Singletons that touch the outside world +├── Extensions/ # Extension.swift — split implementations +├── Utilities/ # OpenAIClient, OCR, helpers +├── Resources/ # Strings.swift, Localizable.xcstrings, Color+Warm.swift, UIConstants.swift +├── Assets.xcassets/ # Images + ColorPalette +├── Savely.entitlements # Sign in with Apple +├── Config.plist # OpenAI API key — GITIGNORED +└── GoogleService-Info.plist # Firebase creds — GITIGNORED + +SavelyTests/ # XCTest unit tests (skeleton only today) +SavelyUITests/ # XCTest UI tests (skeleton only today) + +.claude/ +├── protocols/orchestrator.md # The classify → plan → dispatch flow +├── agents/.md # 6 specialist subagents +├── knowledge/ # Narrative docs each agent can pull +└── commands/orch.md # /orch slash command +``` + +--- + +## Load-bearing constraints + +These are invariants that future changes must respect. They were discovered during the initial survey; new ones get appended to `.claude/knowledge/gotchas.yaml`. + +1. **Signing is automatic, team `ZHLD96SP29`.** Don't switch to manual signing. Don't edit signing build settings. +2. **No `.xcconfig` files exist** — all settings live in `project.pbxproj`. Touching pbxproj is risky; prefer Xcode UI edits and review the diff carefully. +3. **Secrets are gitignored:** `Savely/Config.plist` (OpenAI), `GoogleService-Info.plist` (Firebase), anything matching `.env*`. Never commit these. Never paste their contents. +4. **iOS 26.0 is the minimum deployment target** for the app target. Use modern APIs freely (`@Observable`, `NavigationStack`, Swift Testing, etc.). +5. **Only `Savely.xcscheme` is shared.** Test commands must use `-scheme Savely`, never `-scheme SavelyTests`. +6. **Localization rule:** all user-facing strings go through `Resources/Strings.swift` constants and are registered in `Localizable.xcstrings`. No hardcoded literals in views. +7. **Singletons own external I/O:** `AuthenticationManager.shared`, `UserManager.shared`, `NotificationManager.shared`, `CameraManager.shared`, `OpenAIClient.shared`. Manager bodies are split across `Managers/.swift` and `Extensions/Extension.swift`. +8. **`main` and `dev` are protected.** PRs only, CI must be green, no force-push. No auto-merge — the developer merges manually. +9. **Solo workflow:** one PR at a time. For non-trivial calls (architecture, dependencies, new patterns), present options with trade-offs instead of deciding silently. + +--- + +## Secrets & never-commit list + +| Path / pattern | What it is | +|---|---| +| `Savely/Config.plist` | OpenAI API key | +| `Savely/GoogleService-Info.plist` | Firebase config | +| `.env`, `.env.*` | Any environment files | +| `*.mobileprovision`, `*.provisionprofile` | Provisioning profiles | +| `*.p12`, `*.cer`, `*.certSigningRequest` | Signing certs | +| `AuthKey_*.p8` | App Store Connect API keys | +| `xcuserdata/`, `*.xcuserstate` | User-local Xcode state | +| `DerivedData/`, `build/` | Build artifacts | +| `*.xcresult` | Test result bundles | +| `fastlane/report.xml`, `fastlane/test_output/`, `fastlane/screenshots/` | Fastlane outputs | + +If you see any of these in `git status` about to be staged, **stop and tell the user**. + +--- + +## Specialist routing (quick reference) + +The orchestrator (`.claude/protocols/orchestrator.md`) handles full classification. Quick map: + +| Change touches… | Specialist | +|---|---| +| `Savely/Views/**` or `Savely/ViewModels/**` | `swiftui-feature-specialist` | +| `Savely/Models/**` (incl. SwiftData migrations) | `data-model-specialist` | +| `Savely/Managers/**`, `Extensions/**`, `Utilities/OpenAIClient.swift` | `services-specialist` | +| `Savely.xcodeproj/**`, `.github/workflows/**`, entitlements, SPM bumps | `build-ci-specialist` | +| `SavelyTests/**`, `SavelyUITests/**` | `qa-tester` | +| Branch / commit / PR hygiene, `.gitignore`, `.github/` templates | `git-workflow-specialist` | + +--- + +## Definition of Done (every PR) + +1. `xcodebuild build -scheme Savely` succeeds for `iPhone 16 Pro` simulator. +2. `xcodebuild test -scheme Savely` passes. +3. `swiftlint --strict` exits 0 (soft-skip until SwiftLint is installed). +4. No hardcoded user-facing strings — everything routed through `Strings.swift`. +5. No secrets staged. +6. `CLAUDE.md` updated if a new invariant is introduced. +7. `.claude/knowledge/gotchas.yaml` appended if a workaround was needed. +8. Branch name uses approved prefix; commits follow Conventional Commits. +9. PR opened against `dev` (never `main` directly, except `release/*` branches). CI green. **No auto-merge** — user merges manually. + +--- + +## Pointers + +- Orchestrator: `.claude/protocols/orchestrator.md` +- Specialists: `.claude/agents/.md` +- Knowledge base: `.claude/knowledge/` +- Slash command: `/orch` to start a routed task diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj index ad5e482..56ac5de 100644 --- a/Savely.xcodeproj/project.pbxproj +++ b/Savely.xcodeproj/project.pbxproj @@ -866,7 +866,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -931,7 +931,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -1025,7 +1025,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZHLD96SP29; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1048,7 +1048,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ZHLD96SP29; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..215fe01 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Activate the project's git hooks. +# Run this once after cloning the repo. + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +# Make hook scripts executable +chmod +x .githooks/pre-commit +chmod +x .githooks/commit-msg + +# Point git at our hooks dir +git config core.hooksPath .githooks + +echo "✅ Git hooks activated." +echo "" +echo "Hooks installed:" +echo " pre-commit — runs SwiftLint on staged .swift files" +echo " commit-msg — enforces Conventional Commits format" +echo "" +echo "If you don't have SwiftLint yet:" +echo " brew install swiftlint"