fix: resolve issue #11 - Care Requirements

Automated fix by Tony CI.
Closes #11

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
treyt
2026-02-16 22:42:56 -06:00
parent a46373876b
commit 4e3ced4d64
2 changed files with 201 additions and 18 deletions

View File

@@ -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)
)
}

View File

@@ -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) {