# Implementation Plan: Persist PlantCareInfo in Core Data ## Overview Cache Trefle API care info locally so the API is only called once per plant. This preserves all timing info (watering frequency, fertilizer schedule) for proper notification scheduling. **Goal:** Reduce unnecessary API calls, improve offline experience, preserve care timing data **Estimated Complexity:** Medium **Risk Level:** Low (lightweight migration, optional relationships) --- ## Phase 1: Value Transformers Add JSON-based transformers for complex types that need Core Data persistence. ### Tasks | Task | File | Description | |------|------|-------------| | 1.1 | `Core/Utilities/ValueTransformers.swift` | Add `WateringScheduleTransformer` - encodes `WateringSchedule` struct to JSON Data | | 1.2 | `Core/Utilities/ValueTransformers.swift` | Add `TemperatureRangeTransformer` - encodes `TemperatureRange` struct to JSON Data | | 1.3 | `Core/Utilities/ValueTransformers.swift` | Add `FertilizerScheduleTransformer` - encodes `FertilizerSchedule` struct to JSON Data | | 1.4 | `Core/Utilities/ValueTransformers.swift` | Add `SeasonArrayTransformer` - encodes `[Season]` array to JSON Data | | 1.5 | `App/PlantGuideApp.swift` | Register all 4 new transformers in app init | ### Acceptance Criteria - [ ] All transformers follow existing `IdentificationResultArrayTransformer` pattern - [ ] Transformers handle nil values gracefully - [ ] Round-trip encoding/decoding preserves all data - [ ] Build succeeds with no warnings --- ## Phase 2: Core Data Model Update Add the `PlantCareInfoMO` entity and relationship to `PlantMO`. ### Tasks | Task | File | Description | |------|------|-------------| | 2.1 | `PlantGuideModel.xcdatamodeld` | Create new `PlantCareInfoMO` entity with all attributes (see schema below) | | 2.2 | `PlantGuideModel.xcdatamodeld` | Add `plant` relationship from `PlantCareInfoMO` to `PlantMO` (optional, one-to-one) | | 2.3 | `PlantGuideModel.xcdatamodeld` | Add `plantCareInfo` relationship from `PlantMO` to `PlantCareInfoMO` (optional, cascade delete) | | 2.4 | `ManagedObjects/PlantMO.swift` | Add `@NSManaged public var plantCareInfo: PlantCareInfoMO?` property | ### PlantCareInfoMO Schema | Attribute | Type | Notes | |-----------|------|-------| | `id` | UUID | Required, unique | | `scientificName` | String | Required | | `commonName` | String | Optional | | `lightRequirement` | String | Enum rawValue | | `wateringScheduleData` | Binary | JSON-encoded WateringSchedule | | `temperatureRangeData` | Binary | JSON-encoded TemperatureRange | | `fertilizerScheduleData` | Binary | Optional, JSON-encoded | | `humidity` | String | Optional, enum rawValue | | `growthRate` | String | Optional, enum rawValue | | `bloomingSeasonData` | Binary | Optional, JSON-encoded [Season] | | `additionalNotes` | String | Optional | | `sourceURL` | URI | Optional | | `trefleID` | Integer 32 | Optional | | `fetchedAt` | Date | Required, for cache expiration | ### Acceptance Criteria - [ ] Entity created with all attributes correctly typed - [ ] Relationships defined with proper inverse relationships - [ ] Cascade delete rule set on PlantMO side - [ ] Build succeeds - lightweight migration should auto-apply --- ## Phase 3: Managed Object Implementation Create the `PlantCareInfoMO` managed object class with domain mapping. ### Tasks | Task | File | Description | |------|------|-------------| | 3.1 | `ManagedObjects/PlantCareInfoMO.swift` | Create new file with `@NSManaged` properties | | 3.2 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `toDomainModel() -> PlantCareInfo?` - decodes JSON data to domain structs | | 3.3 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `static func fromDomainModel(_:context:) -> PlantCareInfoMO?` - encodes domain to MO | | 3.4 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `func update(from: PlantCareInfo)` - updates existing MO from domain model | ### Acceptance Criteria - [ ] All `@NSManaged` properties defined - [ ] `toDomainModel()` handles all optional fields correctly - [ ] `fromDomainModel()` creates valid managed object - [ ] `update(from:)` preserves relationships - [ ] JSON encoding/decoding uses transformers correctly --- ## Phase 4: Repository Layer Create the repository protocol and Core Data implementation. ### Tasks | Task | File | Description | |------|------|-------------| | 4.1 | `Domain/RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` | Create protocol with fetch, save, delete, and cache staleness methods | | 4.2 | `Data/DataSources/Local/CoreData/CoreDataPlantCareInfoStorage.swift` | Create implementation conforming to protocol | | 4.3 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(scientificName:)` with predicate query | | 4.4 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(trefleID:)` with predicate query | | 4.5 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(for plantID:)` via relationship | | 4.6 | `CoreDataPlantCareInfoStorage.swift` | Implement `save(_:for:)` - creates or updates MO | | 4.7 | `CoreDataPlantCareInfoStorage.swift` | Implement `isCacheStale(scientificName:cacheExpiration:)` - checks fetchedAt date | | 4.8 | `CoreDataPlantCareInfoStorage.swift` | Implement `delete(for plantID:)` - removes cache entry | ### Protocol Definition ```swift protocol PlantCareInfoRepositoryProtocol: Sendable { func fetch(scientificName: String) async throws -> PlantCareInfo? func fetch(trefleID: Int) async throws -> PlantCareInfo? func fetch(for plantID: UUID) async throws -> PlantCareInfo? func save(_ careInfo: PlantCareInfo, for plantID: UUID?) async throws func isCacheStale(scientificName: String, cacheExpiration: TimeInterval) async throws -> Bool func delete(for plantID: UUID) async throws } ``` ### Acceptance Criteria - [ ] Protocol is `Sendable` for Swift concurrency - [ ] All fetch methods return optional (nil if not found) - [ ] Save method handles both create and update cases - [ ] Cache staleness uses 7-day default expiration - [ ] Delete method handles nil relationship gracefully --- ## Phase 5: Use Case Integration Update `FetchPlantCareUseCase` with cache-first logic. ### Tasks | Task | File | Description | |------|------|-------------| | 5.1 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Inject `PlantCareInfoRepositoryProtocol` dependency | | 5.2 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Add cache check at start of `execute()` | | 5.3 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Add cache staleness validation (7-day expiration) | | 5.4 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Save API response to cache after successful fetch | | 5.5 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Handle cache errors gracefully (fall back to API) | ### Updated Execute Logic ```swift func execute(scientificName: String) async throws -> PlantCareInfo { // 1. Check cache if let cached = try await repository.fetch(scientificName: scientificName), !(try await repository.isCacheStale(scientificName: scientificName, cacheExpiration: 7 * 24 * 60 * 60)) { return cached } // 2. Fetch from API let careInfo = try await fetchFromAPI(scientificName: scientificName) // 3. Cache result try await repository.save(careInfo, for: nil) return careInfo } ``` ### Acceptance Criteria - [ ] Cache hit returns immediately without API call - [ ] Stale cache triggers fresh API fetch - [ ] API response is saved to cache - [ ] Cache errors don't block API fallback - [ ] Timing info (watering interval) preserved in cache --- ## Phase 6: Dependency Injection Wire up all new components in DIContainer. ### Tasks | Task | File | Description | |------|------|-------------| | 6.1 | `Core/DI/DIContainer.swift` | Add `_plantCareInfoStorage` lazy property | | 6.2 | `Core/DI/DIContainer.swift` | Add `plantCareInfoRepository` computed accessor | | 6.3 | `Core/DI/DIContainer.swift` | Update `_fetchPlantCareUseCase` to inject repository | | 6.4 | `Core/DI/DIContainer.swift` | Add storage to `resetAll()` method for testing | ### Acceptance Criteria - [ ] Storage is lazy-initialized - [ ] Repository accessor returns protocol type - [ ] Use case receives repository dependency - [ ] Reset clears cache for testing --- ## Phase 7: Verification Validate the implementation works correctly. ### Tasks | Task | Type | Description | |------|------|-------------| | 7.1 | Build | Run `xcodebuild -scheme PlantGuide build` - verify zero errors | | 7.2 | Manual Test | Add new plant -> view details (should call Trefle API) | | 7.3 | Manual Test | Navigate away and back to details (should NOT call API) | | 7.4 | Manual Test | Verify watering `intervalDays` property works after cache retrieval | | 7.5 | Manual Test | Create care schedule from cached info -> verify notifications | | 7.6 | Cache Expiration | Manually set `fetchedAt` to 8 days ago -> should re-fetch | | 7.7 | Unit Tests | Run `xcodebuild test -scheme PlantGuide` | ### Acceptance Criteria - [ ] Build succeeds with zero warnings - [ ] API only called once per plant (check console logs) - [ ] Cached care info identical to API response - [ ] Care timing preserved for notification scheduling - [ ] Cache expiration triggers refresh after 7 days - [ ] All existing tests pass --- ## Files Summary | File | Action | |------|--------| | `Core/Utilities/ValueTransformers.swift` | MODIFY - Add 4 transformers | | `PlantGuideModel.xcdatamodeld` | MODIFY - Add PlantCareInfoMO entity | | `ManagedObjects/PlantCareInfoMO.swift` | CREATE - Managed object + mappers | | `ManagedObjects/PlantMO.swift` | MODIFY - Add relationship | | `Domain/RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` | CREATE - Protocol | | `Data/DataSources/Local/CoreData/CoreDataPlantCareInfoStorage.swift` | CREATE - Implementation | | `Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift` | MODIFY - Add cache-first logic | | `Core/DI/DIContainer.swift` | MODIFY - Register dependencies | | `App/PlantGuideApp.swift` | MODIFY - Register transformers | --- ## Migration Strategy **Lightweight migration** - no custom mapping model needed: - New entity with no existing data - New relationship is optional (nil default) - `shouldMigrateStoreAutomatically` and `shouldInferMappingModelAutomatically` already enabled --- ## Risk Assessment | Risk | Likelihood | Impact | Mitigation | |------|------------|--------|------------| | Migration failure | Low | High | Lightweight migration; optional relationships | | Cache corruption | Low | Medium | JSON encoding is deterministic; handle decode failures gracefully | | Stale cache served | Low | Low | 7-day expiration; manual refresh available | | Memory pressure | Low | Low | Cache is per-plant, not bulk loaded | --- ## Notes - Follow existing patterns in `IdentificationResultArrayTransformer` for transformers - Use `@NSManaged` properties pattern from existing MO classes - Repository pattern matches existing `PlantCollectionRepositoryProtocol` - Cache expiration of 7 days balances freshness vs API calls - Cascade delete ensures orphan cleanup