From 4e3ced4d64445ccc99dd7fede6209e1010829b17 Mon Sep 17 00:00:00 2001 From: treyt Date: Mon, 16 Feb 2026 22:42:56 -0600 Subject: [PATCH] fix: resolve issue #11 - Care Requirements Automated fix by Tony CI. Closes #11 Co-Authored-By: Claude --- PlantGuide/Data/Mappers/TrefleMapper.swift | 168 ++++++++++++++++-- .../PlantCare/FetchPlantCareUseCase.swift | 51 +++++- 2 files changed, 201 insertions(+), 18 deletions(-) diff --git a/PlantGuide/Data/Mappers/TrefleMapper.swift b/PlantGuide/Data/Mappers/TrefleMapper.swift index 1087ed4..a868ca4 100644 --- a/PlantGuide/Data/Mappers/TrefleMapper.swift +++ b/PlantGuide/Data/Mappers/TrefleMapper.swift @@ -28,14 +28,20 @@ struct TrefleMapper { static func mapToPlantCareInfo(from species: TrefleSpeciesDTO) -> PlantCareInfo { let growth = species.growth let specifications = species.specifications + let family = species.family?.lowercased() + + // Use family-aware defaults when specific growth data is missing. + // Different plant families have very different care needs, so a + // cactus shouldn't get the same defaults as a tropical aroid. + let familyDefaults = familyCareDefaults(for: family) return PlantCareInfo( id: UUID(), scientificName: species.scientificName, commonName: species.commonName, - lightRequirement: mapToLightRequirement(from: growth?.light), - wateringSchedule: mapToWateringSchedule(from: growth), - temperatureRange: mapToTemperatureRange(from: growth), + lightRequirement: mapToLightRequirement(from: growth?.light, familyDefault: familyDefaults.light), + wateringSchedule: mapToWateringSchedule(from: growth, familyDefault: familyDefaults.watering), + temperatureRange: mapToTemperatureRange(from: growth, familyDefault: familyDefaults.temperature), fertilizerSchedule: mapToFertilizerSchedule(from: growth), humidity: mapToHumidityLevel(from: growth?.atmosphericHumidity), growthRate: mapToGrowthRate(from: specifications?.growthRate), @@ -62,9 +68,9 @@ struct TrefleMapper { /// - 3-4: `.lowLight` - Low light but not complete shade /// - 5-6: `.partialShade` - Moderate, indirect light /// - 7-10: `.fullSun` - Direct sunlight for most of the day - static func mapToLightRequirement(from light: Int?) -> LightRequirement { + static func mapToLightRequirement(from light: Int?, familyDefault: LightRequirement = .partialShade) -> LightRequirement { guard let light = light else { - return .partialShade + return familyDefault } switch light { @@ -98,16 +104,19 @@ struct TrefleMapper { /// - High humidity (7-10): Weekly frequency with light watering /// - Medium humidity (4-6): Twice weekly with moderate watering /// - Low humidity (0-3): Weekly with thorough watering - static func mapToWateringSchedule(from growth: TrefleGrowthDTO?) -> WateringSchedule { + static func mapToWateringSchedule( + from growth: TrefleGrowthDTO?, + familyDefault: WateringSchedule = WateringSchedule(frequency: .weekly, amount: .moderate) + ) -> WateringSchedule { guard let growth = growth else { - return WateringSchedule(frequency: .weekly, amount: .moderate) + return familyDefault } // Use atmospheric humidity as primary indicator, fall back to soil humidity let humidityLevel = growth.atmosphericHumidity ?? growth.soilHumidity guard let humidity = humidityLevel else { - return WateringSchedule(frequency: .weekly, amount: .moderate) + return familyDefault } switch humidity { @@ -136,14 +145,12 @@ struct TrefleMapper { /// - Parameter growth: The Trefle growth data containing temperature information. /// - Returns: A `TemperatureRange` with min/max values and frost tolerance. /// Defaults to 15-30 degrees Celsius if growth data is nil. - static func mapToTemperatureRange(from growth: TrefleGrowthDTO?) -> TemperatureRange { + static func mapToTemperatureRange( + from growth: TrefleGrowthDTO?, + familyDefault: TemperatureRange = TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 30.0, optimalCelsius: nil, frostTolerant: false) + ) -> TemperatureRange { guard let growth = growth else { - return TemperatureRange( - minimumCelsius: 15.0, - maximumCelsius: 30.0, - optimalCelsius: nil, - frostTolerant: false - ) + return familyDefault } let minTemp = growth.minimumTemperature?.degC ?? 15.0 @@ -358,4 +365,135 @@ struct TrefleMapper { return notes.isEmpty ? nil : notes.joined(separator: ". ") } + + // MARK: - Family-Aware Defaults + + /// Care defaults grouped by plant family + struct FamilyCareDefaults { + let light: LightRequirement + let watering: WateringSchedule + let temperature: TemperatureRange + } + + /// Returns care defaults appropriate for the given plant family. + /// + /// When the Trefle API returns a species without detailed growth data, + /// these family-based defaults provide more accurate care recommendations + /// than a single generic default for all plants. + /// + /// - Parameter family: The lowercased plant family name (e.g., "cactaceae"). + /// - Returns: Care defaults tuned for that family. + static func familyCareDefaults(for family: String?) -> FamilyCareDefaults { + guard let family = family else { + return genericDefaults + } + + switch family { + // Cacti and succulents + case "cactaceae": + return FamilyCareDefaults( + light: .fullSun, + watering: WateringSchedule(frequency: .biweekly, amount: .light), + temperature: TemperatureRange(minimumCelsius: 10.0, maximumCelsius: 35.0, frostTolerant: false) + ) + + // Succulents (stonecrop family - Jade, Echeveria, Sedum) + case "crassulaceae": + return FamilyCareDefaults( + light: .fullSun, + watering: WateringSchedule(frequency: .biweekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 10.0, maximumCelsius: 30.0, frostTolerant: false) + ) + + // Aloe, Haworthia + case "asphodelaceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .biweekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 10.0, maximumCelsius: 30.0, frostTolerant: false) + ) + + // Tropical aroids (Monstera, Pothos, Philodendron) + case "araceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .weekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 30.0, frostTolerant: false) + ) + + // Ferns + case "polypodiaceae", "pteridaceae", "dryopteridaceae", "aspleniaceae": + return FamilyCareDefaults( + light: .lowLight, + watering: WateringSchedule(frequency: .twiceWeekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 26.0, frostTolerant: false) + ) + + // Orchids + case "orchidaceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .weekly, amount: .light), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 28.0, frostTolerant: false) + ) + + // Ficus, rubber plant family + case "moraceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .weekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 30.0, frostTolerant: false) + ) + + // Spider plant, snake plant, asparagus fern family + case "asparagaceae": + return FamilyCareDefaults( + light: .lowLight, + watering: WateringSchedule(frequency: .weekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 12.0, maximumCelsius: 30.0, frostTolerant: false) + ) + + // Rose family + case "rosaceae": + return FamilyCareDefaults( + light: .fullSun, + watering: WateringSchedule(frequency: .twiceWeekly, amount: .thorough), + temperature: TemperatureRange(minimumCelsius: -5.0, maximumCelsius: 32.0, frostTolerant: true) + ) + + // Palms + case "arecaceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .weekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 32.0, frostTolerant: false) + ) + + // Begonias + case "begoniaceae": + return FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .twiceWeekly, amount: .light), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 28.0, frostTolerant: false) + ) + + // Marantaceae (Calathea, Prayer plant) + case "marantaceae": + return FamilyCareDefaults( + light: .lowLight, + watering: WateringSchedule(frequency: .twiceWeekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 16.0, maximumCelsius: 27.0, frostTolerant: false) + ) + + default: + return genericDefaults + } + } + + /// Generic fallback defaults when no family information is available + private static let genericDefaults = FamilyCareDefaults( + light: .partialShade, + watering: WateringSchedule(frequency: .weekly, amount: .moderate), + temperature: TemperatureRange(minimumCelsius: 15.0, maximumCelsius: 30.0, frostTolerant: false) + ) } diff --git a/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift b/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift index a2e4d47..13d8538 100644 --- a/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift +++ b/PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift @@ -228,13 +228,18 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen // Search for the plant by scientific name let searchResponse = try await trefleAPIService.searchPlants(query: scientificName, page: 1) - // Take the first result from the search - guard let firstResult = searchResponse.data.first else { + guard !searchResponse.data.isEmpty else { throw FetchPlantCareError.speciesNotFound(name: scientificName) } + // Find the best matching result by comparing scientific names. + // The Trefle search is fuzzy, so the first result may not be the + // correct species. We prefer an exact genus+species match, then + // a genus-only match, and finally fall back to the first result. + let bestMatch = findBestMatch(for: scientificName, in: searchResponse.data) + // Fetch full species details using the slug - let speciesResponse = try await trefleAPIService.getSpecies(slug: firstResult.slug) + let speciesResponse = try await trefleAPIService.getSpecies(slug: bestMatch.slug) let species = speciesResponse.data // Map to PlantCareInfo using TrefleMapper @@ -257,6 +262,46 @@ final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sen } } + /// Finds the best matching search result for the given scientific name. + /// + /// Matching priority: + /// 1. Exact scientific name match (case-insensitive) + /// 2. Scientific name starts with the query (handles author suffixes like "L.") + /// 3. Same genus (first word of scientific name) + /// 4. First result as fallback + private func findBestMatch( + for scientificName: String, + in results: [TreflePlantSummaryDTO] + ) -> TreflePlantSummaryDTO { + let queryLower = scientificName.lowercased().trimmingCharacters(in: .whitespaces) + let queryGenus = queryLower.components(separatedBy: " ").first ?? queryLower + + // 1. Exact match + if let exact = results.first(where: { + $0.scientificName.lowercased() == queryLower + }) { + return exact + } + + // 2. Result whose scientific name starts with our query + // (handles cases like "Rosa gallica L." matching "Rosa gallica") + if let prefixMatch = results.first(where: { + $0.scientificName.lowercased().hasPrefix(queryLower) + }) { + return prefixMatch + } + + // 3. Same genus match + if let genusMatch = results.first(where: { + $0.scientificName.lowercased().components(separatedBy: " ").first == queryGenus + }) { + return genusMatch + } + + // 4. Fall back to first result + return results.first! + } + func execute(trefleId: Int, forceRefresh: Bool = false) async throws -> PlantCareInfo { // 1. Check cache first (unless force refresh is requested) if !forceRefresh, let cached = try? await cacheRepository?.fetch(trefleID: trefleId) {