From 9eab3825b9c27da120f42ba63ea2656249b32d7f Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Sun, 25 Jan 2026 19:54:48 -1000 Subject: [PATCH 1/8] Layout fixes for auth screens --- apps/api/.env.example | 3 + apps/api/package.json | 1 + apps/api/src/config/index.ts | 3 + apps/api/src/config/types.ts | 3 + .../trpc/species/getFertilizerSuggestions.ts | 167 ++++++++++++++++++ apps/api/src/routers/trpc/species.ts | 2 + apps/mobile/src/app/(tabs)/plants.tsx | 61 +++++++ apps/mobile/src/app/create-account.tsx | 5 +- apps/mobile/src/app/forgot-password.tsx | 5 +- apps/mobile/src/app/login.tsx | 1 + apps/mobile/src/app/reset-password.tsx | 5 +- package-lock.json | 45 +++++ 12 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index a578565..d2db2ea 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -38,3 +38,6 @@ EXPO_ACCESS_TOKEN=**** # Trefle plant API TREFLE_ACCESS_TOKEN=usr-**** + +# OpenAI API key +OPENAI_API_KEY=**** diff --git a/apps/api/package.json b/apps/api/package.json index b6ae518..7fb2919 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "mongodb": "^6.19.0", "mongoose": "^8.18.1", "nodemailer": "^6.9.8", + "openai": "^6.16.0", "superjson": "^1.13.3", "zod": "^4.1.9" }, diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 27b3528..cc334bd 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -33,6 +33,9 @@ export const config: ApiConfig = { trefle: { accessToken: process.env.TREFLE_ACCESS_TOKEN, }, + openai: { + apiKey: process.env.OPENAI_API_KEY, + }, } export * from './types' diff --git a/apps/api/src/config/types.ts b/apps/api/src/config/types.ts index f5d2034..1cb4d34 100644 --- a/apps/api/src/config/types.ts +++ b/apps/api/src/config/types.ts @@ -30,4 +30,7 @@ export type ApiConfig = { trefle: { accessToken: string | undefined, }, + openai: { + apiKey: string | undefined, + }, } diff --git a/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts b/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts new file mode 100644 index 0000000..618ce2d --- /dev/null +++ b/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts @@ -0,0 +1,167 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import OpenAI from 'openai' + +import { config } from '../../../config' + +import { Fertilizer, Species } from '../../../models' + +import { authProcedure } from '../../../procedures/authProcedure' + +export const getFertilizerSuggestions = authProcedure + .input(z.object({ + speciesId: z.string().optional(), + name: z.string().optional(), + }).refine( + (data) => data.speciesId || data.name, + { + message: 'Either speciesId or name must be provided', + } + )) + .query(async ({ ctx, input }) => { + // Find species by ID or name + let species + if (input.speciesId) { + species = await Species.findById(input.speciesId) + } else if (input.name) { + // Search by common name or scientific name + species = await Species.findOne({ + $or: [ + { commonName: { $regex: input.name, $options: 'i' } }, + { scientificName: { $regex: input.name, $options: 'i' } }, + ], + }) + } + + if (!species) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Species not found', + }) + } + + // Fetch user's fertilizers + const userFertilizers = await Fertilizer.find({ + user: ctx.userId, + deletedAt: null, + }).sort({ name: 'asc' }) + + // Initialize OpenAI client + if (!config.openai.apiKey) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'OpenAI API key is not configured', + }) + } + + const openai = new OpenAI({ + apiKey: config.openai.apiKey, + }) + + // Build prompt with species information + const speciesInfo = [ + species.commonName && `Common name: ${species.commonName}`, + species.scientificName && `Scientific name: ${species.scientificName}`, + species.genus && `Genus: ${species.genus}`, + species.family && `Family: ${species.family}`, + species.familyCommonName && `Family common name: ${species.familyCommonName}`, + ].filter(Boolean).join('\n') + + // Format user's fertilizers for the prompt + const userFertilizersList = userFertilizers.length > 0 + ? userFertilizers.map(fertilizer => { + const npk = [ + fertilizer.nitrogen !== null ? `N:${fertilizer.nitrogen}` : null, + fertilizer.phosphorus !== null ? `P:${fertilizer.phosphorus}` : null, + fertilizer.potassium !== null ? `K:${fertilizer.potassium}` : null, + ].filter(Boolean).join('-') || 'NPK not specified' + +// return `- ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk}${fertilizer.notes ? `, Notes: ${fertilizer.notes}` : ''})` + return `- ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk})` + }).join('\n') + : 'None' + + const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: +- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) +- Application frequency and timing +- Specific nutrient requirements +- Any special considerations for this species + +Plant information: +${speciesInfo} + +The user currently owns the following fertilizers: +${userFertilizersList} + +Please provide: +1. A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant. +2. A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly. +3. A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species. + +Format your response as JSON with the following structure: +{ + "generalRecommendations": "Your detailed text response here", + "recommendedFromInventory": ["fertilizer name 1", "fertilizer name 2", ...], + "recommendedOtherProducts": ["Brand Name - Product Name", "Brand Name - Product Name", ...] +} + +If no fertilizers from the inventory are suitable, set "recommendedFromInventory" to an empty array. +If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedOtherProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` + + // Call OpenAI API + let suggestions: string + let recommendedFromInventory: string[] = [] + let recommendedOtherProducts: string[] = [] + + try { + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are a helpful plant care expert specializing in fertilizer recommendations for different plant species. Always respond with valid JSON in the exact format requested.', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.7, + response_format: { type: 'json_object' }, + }) + + const responseContent = completion.choices[0]?.message?.content || '{}' + + try { + const parsedResponse = JSON.parse(responseContent) + suggestions = parsedResponse.generalRecommendations || 'No general recommendations available' + recommendedFromInventory = Array.isArray(parsedResponse.recommendedFromInventory) + ? parsedResponse.recommendedFromInventory + : [] + recommendedOtherProducts = Array.isArray(parsedResponse.recommendedOtherProducts) + ? parsedResponse.recommendedOtherProducts + : [] + } catch (parseError) { + // If JSON parsing fails, use the raw response as suggestions + suggestions = responseContent + recommendedFromInventory = [] + recommendedOtherProducts = [] + } + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to get fertilizer suggestions from OpenAI: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + } + + return { + suggestions, + recommendedFromInventory, + recommendedOtherProducts, + species: { + id: species._id.toString(), + commonName: species.commonName, + scientificName: species.scientificName, + }, + } + }) diff --git a/apps/api/src/routers/trpc/species.ts b/apps/api/src/routers/trpc/species.ts index 287d806..c426f6c 100644 --- a/apps/api/src/routers/trpc/species.ts +++ b/apps/api/src/routers/trpc/species.ts @@ -1,8 +1,10 @@ import { router } from '../../trpc' +import { getFertilizerSuggestions } from '../../endpoints/trpc/species/getFertilizerSuggestions' import { listSpecies } from '../../endpoints/trpc/species/listSpecies' export const speciesRouter = router({ + getFertilizerSuggestions, list: listSpecies, }) diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index 06157eb..0eb94a7 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -842,6 +842,12 @@ function AddEditPlantModal({ { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } ) + // Fetch fertilizer suggestions when a species is selected (only in add mode) + const { data: fertilizerSuggestionsData, isLoading: isLoadingFertilizerSuggestions } = trpc.species.getFertilizerSuggestions.useQuery( + { speciesId: selectedSpecies?._id || undefined }, + { enabled: mode === 'add' && !!selectedSpecies?._id } + ) + // When the modal is opened or closed, set the default form state React.useEffect(() => { if (!isVisible) { @@ -968,6 +974,61 @@ function AddEditPlantModal({ species={selectedSpecies} onClose={handleClearSpecies} /> + {mode === 'add' && ( + <> + Fertilizer Suggestions + {isLoadingFertilizerSuggestions ? ( + + + + Getting fertilizer recommendations... + + + ) : fertilizerSuggestionsData?.suggestions ? ( + + + {fertilizerSuggestionsData.suggestions} + + + {fertilizerSuggestionsData.recommendedFromInventory && fertilizerSuggestionsData.recommendedFromInventory.length > 0 && ( + <> + + Recommended from Your Inventory + + {fertilizerSuggestionsData.recommendedFromInventory.map((fertilizer, index) => ( + + • {fertilizer} + + ))} + + )} + + {fertilizerSuggestionsData.recommendedOtherProducts && fertilizerSuggestionsData.recommendedOtherProducts.length > 0 && ( + <> + + Other Recommended Products + + {fertilizerSuggestionsData.recommendedOtherProducts.map((product, index) => ( + + • {product} + + ))} + + )} + + ) : null} + + )} )) || ( <> diff --git a/apps/mobile/src/app/create-account.tsx b/apps/mobile/src/app/create-account.tsx index 61a6b63..92e61b1 100644 --- a/apps/mobile/src/app/create-account.tsx +++ b/apps/mobile/src/app/create-account.tsx @@ -68,7 +68,7 @@ export default function CreateAccountScreen() { } return ( - + Create Account Sign up for Plannting @@ -134,6 +134,9 @@ export default function CreateAccountScreen() { } const localStyles = StyleSheet.create({ + container: { + alignItems: 'center', + }, form: { width: '100%', maxWidth: 400, diff --git a/apps/mobile/src/app/forgot-password.tsx b/apps/mobile/src/app/forgot-password.tsx index 147d303..7a06d94 100644 --- a/apps/mobile/src/app/forgot-password.tsx +++ b/apps/mobile/src/app/forgot-password.tsx @@ -45,7 +45,7 @@ export default function ForgotPasswordScreen() { } return ( - + Forgot Password @@ -88,6 +88,9 @@ export default function ForgotPasswordScreen() { } const localStyles = StyleSheet.create({ + container: { + alignItems: 'center', + }, form: { width: '100%', maxWidth: 400, diff --git a/apps/mobile/src/app/login.tsx b/apps/mobile/src/app/login.tsx index 9fc11f5..273a000 100644 --- a/apps/mobile/src/app/login.tsx +++ b/apps/mobile/src/app/login.tsx @@ -109,6 +109,7 @@ export default function LoginScreen() { const localStyles = StyleSheet.create({ container: { justifyContent: 'center', + alignItems: 'center', }, form: { width: '100%', diff --git a/apps/mobile/src/app/reset-password.tsx b/apps/mobile/src/app/reset-password.tsx index 480f86e..2cffbaf 100644 --- a/apps/mobile/src/app/reset-password.tsx +++ b/apps/mobile/src/app/reset-password.tsx @@ -104,7 +104,7 @@ export default function ResetPasswordScreen() { } return ( - + Reset Password @@ -188,6 +188,9 @@ export default function ResetPasswordScreen() { } const localStyles = StyleSheet.create({ + container: { + alignItems: 'center', + }, form: { width: '100%', maxWidth: 400, diff --git a/package-lock.json b/package-lock.json index d07d9a5..35e64ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "mongodb": "^6.19.0", "mongoose": "^8.18.1", "nodemailer": "^6.9.8", + "openai": "^6.16.0", "superjson": "^1.13.3", "zod": "^4.1.9" }, @@ -84,6 +85,50 @@ "date-fns": "^3.0.0 || ^4.0.0" } }, + "apps/api/node_modules/openai": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", + "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "apps/api/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "apps/mobile": { "name": "@plannting/mobile", "version": "0.8.0", From ec14e1dd19ad689660dcae831d1df21549888037 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 27 Jan 2026 20:56:03 -1000 Subject: [PATCH 2/8] Species-specific and SpeciesUser-specific fertilizerSuggestions --- .../trpc/species/getFertilizerSuggestions.ts | 166 +------------- apps/api/src/models/Species.ts | 13 ++ apps/api/src/models/SpeciesUser.ts | 35 +++ apps/api/src/models/index.ts | 1 + apps/api/src/services/openAi/index.ts | 14 ++ apps/api/src/services/species/index.ts | 216 ++++++++++++++++++ apps/mobile/src/app/(tabs)/plants.tsx | 20 +- 7 files changed, 300 insertions(+), 165 deletions(-) create mode 100644 apps/api/src/models/SpeciesUser.ts create mode 100644 apps/api/src/services/openAi/index.ts create mode 100644 apps/api/src/services/species/index.ts diff --git a/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts b/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts index 618ce2d..83f4986 100644 --- a/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts +++ b/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts @@ -1,167 +1,23 @@ import { z } from 'zod' -import { TRPCError } from '@trpc/server' -import OpenAI from 'openai' - -import { config } from '../../../config' - -import { Fertilizer, Species } from '../../../models' import { authProcedure } from '../../../procedures/authProcedure' +import { getFertilizerSuggestions as getFertilizerSuggestionsService } from '../../../services/species' + export const getFertilizerSuggestions = authProcedure .input(z.object({ - speciesId: z.string().optional(), - name: z.string().optional(), - }).refine( - (data) => data.speciesId || data.name, - { - message: 'Either speciesId or name must be provided', - } - )) + speciesId: z.string(), + })) .query(async ({ ctx, input }) => { - // Find species by ID or name - let species - if (input.speciesId) { - species = await Species.findById(input.speciesId) - } else if (input.name) { - // Search by common name or scientific name - species = await Species.findOne({ - $or: [ - { commonName: { $regex: input.name, $options: 'i' } }, - { scientificName: { $regex: input.name, $options: 'i' } }, - ], - }) - } - - if (!species) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Species not found', - }) - } - - // Fetch user's fertilizers - const userFertilizers = await Fertilizer.find({ - user: ctx.userId, - deletedAt: null, - }).sort({ name: 'asc' }) - - // Initialize OpenAI client - if (!config.openai.apiKey) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'OpenAI API key is not configured', - }) - } - - const openai = new OpenAI({ - apiKey: config.openai.apiKey, + // userId is always defined in authProcedure + const fertilizerSuggestions = await getFertilizerSuggestionsService({ + speciesId: input.speciesId, + userId: ctx.userId!, }) - // Build prompt with species information - const speciesInfo = [ - species.commonName && `Common name: ${species.commonName}`, - species.scientificName && `Scientific name: ${species.scientificName}`, - species.genus && `Genus: ${species.genus}`, - species.family && `Family: ${species.family}`, - species.familyCommonName && `Family common name: ${species.familyCommonName}`, - ].filter(Boolean).join('\n') - - // Format user's fertilizers for the prompt - const userFertilizersList = userFertilizers.length > 0 - ? userFertilizers.map(fertilizer => { - const npk = [ - fertilizer.nitrogen !== null ? `N:${fertilizer.nitrogen}` : null, - fertilizer.phosphorus !== null ? `P:${fertilizer.phosphorus}` : null, - fertilizer.potassium !== null ? `K:${fertilizer.potassium}` : null, - ].filter(Boolean).join('-') || 'NPK not specified' - -// return `- ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk}${fertilizer.notes ? `, Notes: ${fertilizer.notes}` : ''})` - return `- ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk})` - }).join('\n') - : 'None' - - const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: -- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) -- Application frequency and timing -- Specific nutrient requirements -- Any special considerations for this species - -Plant information: -${speciesInfo} - -The user currently owns the following fertilizers: -${userFertilizersList} - -Please provide: -1. A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant. -2. A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly. -3. A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species. - -Format your response as JSON with the following structure: -{ - "generalRecommendations": "Your detailed text response here", - "recommendedFromInventory": ["fertilizer name 1", "fertilizer name 2", ...], - "recommendedOtherProducts": ["Brand Name - Product Name", "Brand Name - Product Name", ...] -} - -If no fertilizers from the inventory are suitable, set "recommendedFromInventory" to an empty array. -If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedOtherProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` - - // Call OpenAI API - let suggestions: string - let recommendedFromInventory: string[] = [] - let recommendedOtherProducts: string[] = [] - - try { - const completion = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: 'You are a helpful plant care expert specializing in fertilizer recommendations for different plant species. Always respond with valid JSON in the exact format requested.', - }, - { - role: 'user', - content: prompt, - }, - ], - temperature: 0.7, - response_format: { type: 'json_object' }, - }) - - const responseContent = completion.choices[0]?.message?.content || '{}' - - try { - const parsedResponse = JSON.parse(responseContent) - suggestions = parsedResponse.generalRecommendations || 'No general recommendations available' - recommendedFromInventory = Array.isArray(parsedResponse.recommendedFromInventory) - ? parsedResponse.recommendedFromInventory - : [] - recommendedOtherProducts = Array.isArray(parsedResponse.recommendedOtherProducts) - ? parsedResponse.recommendedOtherProducts - : [] - } catch (parseError) { - // If JSON parsing fails, use the raw response as suggestions - suggestions = responseContent - recommendedFromInventory = [] - recommendedOtherProducts = [] - } - } catch (error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to get fertilizer suggestions from OpenAI: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) - } - return { - suggestions, - recommendedFromInventory, - recommendedOtherProducts, - species: { - id: species._id.toString(), - commonName: species.commonName, - scientificName: species.scientificName, - }, + text: fertilizerSuggestions.text, + recommendedUserFertilizers: 'recommendedUserFertilizers' in fertilizerSuggestions ? fertilizerSuggestions.recommendedUserFertilizers : [], + recommendedProducts: fertilizerSuggestions.recommendedProducts, } }) diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index e39c7ea..463ad08 100644 --- a/apps/api/src/models/Species.ts +++ b/apps/api/src/models/Species.ts @@ -1,5 +1,10 @@ import mongoose from 'mongoose' +export interface ISpeciesFertilizerSuggestions { + text: string, + recommendedProducts: string[], +} + export interface ISpecies { _id: string, source: 'trefle', @@ -11,6 +16,7 @@ export interface ISpecies { synonyms: string[] | null, genus: string | null, family: string | null, + fertilizerSuggestions: ISpeciesFertilizerSuggestions | null, createdAt: Date, updatedAt: Date, } @@ -54,6 +60,13 @@ export const speciesSchema = new mongoose.Schema({ type: String, default: null, }, + fertilizerSuggestions: { + type: { + text: String, + recommendedProducts: [String], + }, + default: null, + }, }, { collation: { locale: 'en', strength: 2 }, timestamps: true, diff --git a/apps/api/src/models/SpeciesUser.ts b/apps/api/src/models/SpeciesUser.ts new file mode 100644 index 0000000..603efe5 --- /dev/null +++ b/apps/api/src/models/SpeciesUser.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose' + +export interface ISpeciesUser { + _id: string, + species: mongoose.Types.ObjectId, + user: mongoose.Types.ObjectId, + recommendedFertilizers: mongoose.Types.ObjectId[], + createdAt: Date, + updatedAt: Date, +} + +export const speciesUserSchema = new mongoose.Schema({ + species: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Species', + required: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + recommendedFertilizers: { + type: [mongoose.Schema.Types.ObjectId], + ref: 'Fertilizer', + }, +}, { + collation: { locale: 'en', strength: 2 }, + timestamps: true, +}) + +// Create unique index on species + user to prevent duplicates +speciesUserSchema.index({ species: 1, user: 1 }, { unique: true }) + +export const SpeciesUser = mongoose.model('SpeciesUser', speciesUserSchema) diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index d276011..67b9477 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -5,4 +5,5 @@ export * from './PasswordResetToken' export * from './Plant' export * from './PlantLifecycleEvent' export * from './Species' +export * from './SpeciesUser' export * from './User' diff --git a/apps/api/src/services/openAi/index.ts b/apps/api/src/services/openAi/index.ts new file mode 100644 index 0000000..ce796de --- /dev/null +++ b/apps/api/src/services/openAi/index.ts @@ -0,0 +1,14 @@ +import OpenAI from 'openai' + +import { config } from '../../config' + +export const createClient = () => { + // Initialize OpenAI client + if (!config.openai.apiKey) { + throw new Error('OpenAI API key is not configured') + } + + return new OpenAI({ + apiKey: config.openai.apiKey, + }) +} diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts new file mode 100644 index 0000000..8d56af1 --- /dev/null +++ b/apps/api/src/services/species/index.ts @@ -0,0 +1,216 @@ +import { TRPCError } from '@trpc/server' + +import { + Fertilizer, + Species, + SpeciesUser, + type IFertilizer, + type ISpeciesFertilizerSuggestions, +} from '../../models' + +import * as openAiService from '../openAi' + +export const getFertilizerSuggestions = async ({ + speciesId, + userId, +}: { + speciesId: string, + userId?: string, +}): Promise => { + // Get species + const species = await Species.findById(speciesId) + + if (!species) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Species not found', + }) + } + + const speciesSuggestions = species.toObject().fertilizerSuggestions + + // Check if SpeciesUser record already exists + const speciesUser = await SpeciesUser.findOne({ + species: speciesId, + user: userId, + }) + .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') + + // If we already have both species-level suggestions and user-level fertilizer recommendations (if userId was passed), return the existing data + if (speciesSuggestions && (!userId || speciesUser?.recommendedFertilizers)) { + const existingData = { + ...speciesSuggestions, + recommendedUserFertilizers: speciesUser?.recommendedFertilizers || [], + } + + return existingData + } + + // Fetch user's fertilizers + const userFertilizers = !userId ? [] : await Fertilizer.find({ + user: userId, + deletedAt: null, + }).sort({ name: 'asc' }) + + const openai = openAiService.createClient() + + // Build species information for prompt + const speciesInfo = [ + species.commonName && `Common name: ${species.commonName}`, + species.scientificName && `Scientific name: ${species.scientificName}`, + species.genus && `Genus: ${species.genus}`, + species.family && `Family: ${species.family}`, + species.familyCommonName && `Family common name: ${species.familyCommonName}`, + ].filter(Boolean).join('\n') + + // Gather prompt instructions + let promptPrefix = '' + let promptInstructionsSpec = [] + let promptSuffix = `If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` + + const responseFormatSpec: { + generalRecommendations: string, + recommendedUserFertilizerIds: string[], + recommendedProducts: string[], + } = { + generalRecommendations: 'Your detailed text response here', + recommendedUserFertilizerIds: [], + recommendedProducts: ['Brand Name - Product Name (N-P-K)', 'Brand Name - Product Name (N-P-K)'], + } + + if (!speciesSuggestions?.text) { + promptInstructionsSpec.push('A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant.') + } + + if (userFertilizers && userFertilizers.length > 0) { + const userFertilizersList = userFertilizers + .map(fertilizer => { + const npk = [ + fertilizer.nitrogen !== null ? `N:${fertilizer.nitrogen}` : null, + fertilizer.phosphorus !== null ? `P:${fertilizer.phosphorus}` : null, + fertilizer.potassium !== null ? `K:${fertilizer.potassium}` : null, + ].filter(Boolean).join('-') || 'NPK not specified' + + return `- (id: ${fertilizer._id}) ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk})` + }) + .join('\n') + + promptPrefix = `The user currently owns the following fertilizers:\n${userFertilizersList}` + + promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") + + if (!speciesSuggestions?.recommendedProducts.length) { + promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") + } + + promptSuffix = `If no fertilizers from the inventory are suitable, set "recommendedUserFertilizerIds" to an empty array.\n\n${promptSuffix}` + + responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] + } else { + if (!speciesSuggestions?.recommendedProducts.length) { + promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") + } + } + + const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: +- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) +- Application frequency and timing +- Specific nutrient requirements +- Any special considerations for this species + +Plant information: +${speciesInfo} + +${promptPrefix} + +Please provide: +${promptInstructionsSpec.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n')} + +Format your response as JSON with the following structure: +${JSON.stringify(responseFormatSpec)} + +${promptSuffix} +` + + // Call OpenAI API + let text: string + let recommendedUserFertilizerIds: string[] = [] + let recommendedProducts: string[] = [] + + try { + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are a helpful plant care expert specializing in fertilizer recommendations for different plant species. Always respond with valid JSON in the exact format requested.', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.7, + response_format: { type: 'json_object' }, + }) + + const responseContent = completion.choices[0]?.message?.content || '{}' + +// Should use zod to be type safe when parsing the response + try { + const parsedResponse = JSON.parse(responseContent) + + text = parsedResponse.generalRecommendations || 'No general recommendations available' + recommendedUserFertilizerIds = Array.isArray(parsedResponse.recommendedUserFertilizerIds) + ? parsedResponse.recommendedUserFertilizerIds + : [] + recommendedProducts = Array.isArray(parsedResponse.recommendedProducts) + ? parsedResponse.recommendedProducts + : [] + } catch (parseError) { + // If JSON parsing fails, use the raw response as suggestions + text = responseContent + recommendedUserFertilizerIds = [] + recommendedProducts = [] + } + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to get fertilizer suggestions from OpenAI: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + } + + const newSpeciesSuggestions: ISpeciesFertilizerSuggestions | null = (text && recommendedProducts) ? { + text, + recommendedProducts, + } : null + + // Store species-level suggestions (text and recommendedProducts) on Species if they do not already exist + if (!species.fertilizerSuggestions) { + species.fertilizerSuggestions = newSpeciesSuggestions + + await species.save() + } + + // Gather recommended user fertilizers + const recommendedUserFertilizers: IFertilizer[] = recommendedUserFertilizerIds + .map(id => userFertilizers.find(f => f._id.toString() === id) as IFertilizer) + .filter(Boolean) + + // Store user-specific suggestions (recommendedUserFertilizers) on SpeciesUser if they do not already exist + const newSpeciesUser = await SpeciesUser.create({ + species: speciesId, + user: userId, + recommendedFertilizers: recommendedUserFertilizers, + }) + const newSpeciesUserPopulated = await SpeciesUser.findById(newSpeciesUser._id) + .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') + + const response: ISpeciesFertilizerSuggestions & { recommendedUserFertilizers: IFertilizer[] } = { + text: (newSpeciesSuggestions || speciesSuggestions)?.text || '', + recommendedProducts: (newSpeciesSuggestions || speciesSuggestions)?.recommendedProducts || [], + recommendedUserFertilizers: (newSpeciesUserPopulated?.toObject() || speciesUser?.toObject())?.recommendedFertilizers || [], + } + + return response +} diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index 0eb94a7..e8836f6 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -844,7 +844,7 @@ function AddEditPlantModal({ // Fetch fertilizer suggestions when a species is selected (only in add mode) const { data: fertilizerSuggestionsData, isLoading: isLoadingFertilizerSuggestions } = trpc.species.getFertilizerSuggestions.useQuery( - { speciesId: selectedSpecies?._id || undefined }, + { speciesId: selectedSpecies?._id ?? '' }, { enabled: mode === 'add' && !!selectedSpecies?._id } ) @@ -989,7 +989,7 @@ function AddEditPlantModal({ Getting fertilizer recommendations... - ) : fertilizerSuggestionsData?.suggestions ? ( + ) : fertilizerSuggestionsData?.text ? ( - {fertilizerSuggestionsData.suggestions} + {fertilizerSuggestionsData.text} - - {fertilizerSuggestionsData.recommendedFromInventory && fertilizerSuggestionsData.recommendedFromInventory.length > 0 && ( + + {fertilizerSuggestionsData.recommendedUserFertilizers && fertilizerSuggestionsData.recommendedUserFertilizers.length > 0 && ( <> Recommended from Your Inventory - {fertilizerSuggestionsData.recommendedFromInventory.map((fertilizer, index) => ( + {fertilizerSuggestionsData.recommendedUserFertilizers.map((fertilizer, index) => ( - • {fertilizer} + • {fertilizer.name} ({fertilizer.nitrogen !== null ? fertilizer.nitrogen : null}-{fertilizer.phosphorus !== null ? fertilizer.phosphorus : null}-{fertilizer.potassium !== null ? fertilizer.potassium : null}) ))} )} - - {fertilizerSuggestionsData.recommendedOtherProducts && fertilizerSuggestionsData.recommendedOtherProducts.length > 0 && ( + + {fertilizerSuggestionsData.recommendedProducts && fertilizerSuggestionsData.recommendedProducts.length > 0 && ( <> Other Recommended Products - {fertilizerSuggestionsData.recommendedOtherProducts.map((product, index) => ( + {fertilizerSuggestionsData.recommendedProducts.map((product, index) => ( • {product} From f004578f33e8cc74b4aa5a52fb8d4018b8a1a67d Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 27 Jan 2026 20:57:40 -1000 Subject: [PATCH 3/8] Remove todo comment --- apps/api/src/services/species/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index 8d56af1..11f00f0 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -156,7 +156,6 @@ ${promptSuffix} const responseContent = completion.choices[0]?.message?.content || '{}' -// Should use zod to be type safe when parsing the response try { const parsedResponse = JSON.parse(responseContent) From 28782e7b6a7def5370cf659adc884f996cad2cb5 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 27 Jan 2026 21:49:31 -1000 Subject: [PATCH 4/8] Move plant details to separate screen and fetch fertilizer recommendations --- apps/api/package.json | 2 +- .../species/getFertilizerRecommendations.ts | 23 + .../trpc/species/getFertilizerSuggestions.ts | 23 - apps/api/src/models/Species.ts | 6 +- apps/api/src/routers/trpc/species.ts | 4 +- apps/api/src/services/species/index.ts | 32 +- apps/mobile/package.json | 2 +- apps/mobile/src/app/(tabs)/plants.tsx | 823 +----------------- apps/mobile/src/app/chores/[id].tsx | 2 +- apps/mobile/src/app/chores/_layout.tsx | 1 - apps/mobile/src/app/plants/[id].tsx | 460 ++++++++++ apps/mobile/src/app/plants/_layout.tsx | 19 + .../src/components/AddEditPlantModal.tsx | 361 ++++++++ .../components/FertilizerRecommendations.tsx | 86 ++ apps/mobile/src/components/PlantHistogram.tsx | 102 +++ apps/mobile/src/components/SpeciesCard.tsx | 46 + apps/mobile/src/styles.ts | 2 +- 17 files changed, 1136 insertions(+), 858 deletions(-) create mode 100644 apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts delete mode 100644 apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts create mode 100644 apps/mobile/src/app/plants/[id].tsx create mode 100644 apps/mobile/src/app/plants/_layout.tsx create mode 100644 apps/mobile/src/components/AddEditPlantModal.tsx create mode 100644 apps/mobile/src/components/FertilizerRecommendations.tsx create mode 100644 apps/mobile/src/components/PlantHistogram.tsx create mode 100644 apps/mobile/src/components/SpeciesCard.tsx diff --git a/apps/api/package.json b/apps/api/package.json index 7fb2919..a7b2469 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@plannting/api", - "version": "0.5.0", + "version": "0.6.0", "private": true, "main": "dist/index.js", "scripts": { diff --git a/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts b/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts new file mode 100644 index 0000000..fbbac2c --- /dev/null +++ b/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +import { authProcedure } from '../../../procedures/authProcedure' + +import { getFertilizerRecommendations as getFertilizerRecommendationsService } from '../../../services/species' + +export const getFertilizerRecommendations = authProcedure + .input(z.object({ + speciesId: z.string(), + })) + .query(async ({ ctx, input }) => { + // userId is always defined in authProcedure + const fertilizerRecommendations = await getFertilizerRecommendationsService({ + speciesId: input.speciesId, + userId: ctx.userId!, + }) + + return { + text: fertilizerRecommendations.text, + recommendedUserFertilizers: 'recommendedUserFertilizers' in fertilizerRecommendations ? fertilizerRecommendations.recommendedUserFertilizers : [], + recommendedProducts: fertilizerRecommendations.recommendedProducts, + } + }) diff --git a/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts b/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts deleted file mode 100644 index 83f4986..0000000 --- a/apps/api/src/endpoints/trpc/species/getFertilizerSuggestions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod' - -import { authProcedure } from '../../../procedures/authProcedure' - -import { getFertilizerSuggestions as getFertilizerSuggestionsService } from '../../../services/species' - -export const getFertilizerSuggestions = authProcedure - .input(z.object({ - speciesId: z.string(), - })) - .query(async ({ ctx, input }) => { - // userId is always defined in authProcedure - const fertilizerSuggestions = await getFertilizerSuggestionsService({ - speciesId: input.speciesId, - userId: ctx.userId!, - }) - - return { - text: fertilizerSuggestions.text, - recommendedUserFertilizers: 'recommendedUserFertilizers' in fertilizerSuggestions ? fertilizerSuggestions.recommendedUserFertilizers : [], - recommendedProducts: fertilizerSuggestions.recommendedProducts, - } - }) diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index 463ad08..c76e036 100644 --- a/apps/api/src/models/Species.ts +++ b/apps/api/src/models/Species.ts @@ -1,6 +1,6 @@ import mongoose from 'mongoose' -export interface ISpeciesFertilizerSuggestions { +export interface ISpeciesFertilizerRecommendations { text: string, recommendedProducts: string[], } @@ -16,7 +16,7 @@ export interface ISpecies { synonyms: string[] | null, genus: string | null, family: string | null, - fertilizerSuggestions: ISpeciesFertilizerSuggestions | null, + fertilizerRecommendations: ISpeciesFertilizerRecommendations | null, createdAt: Date, updatedAt: Date, } @@ -60,7 +60,7 @@ export const speciesSchema = new mongoose.Schema({ type: String, default: null, }, - fertilizerSuggestions: { + fertilizerRecommendations: { type: { text: String, recommendedProducts: [String], diff --git a/apps/api/src/routers/trpc/species.ts b/apps/api/src/routers/trpc/species.ts index c426f6c..0fc751e 100644 --- a/apps/api/src/routers/trpc/species.ts +++ b/apps/api/src/routers/trpc/species.ts @@ -1,10 +1,10 @@ import { router } from '../../trpc' -import { getFertilizerSuggestions } from '../../endpoints/trpc/species/getFertilizerSuggestions' +import { getFertilizerRecommendations } from '../../endpoints/trpc/species/getFertilizerRecommendations' import { listSpecies } from '../../endpoints/trpc/species/listSpecies' export const speciesRouter = router({ - getFertilizerSuggestions, + getFertilizerRecommendations, list: listSpecies, }) diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index 11f00f0..46a238e 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -5,18 +5,18 @@ import { Species, SpeciesUser, type IFertilizer, - type ISpeciesFertilizerSuggestions, + type ISpeciesFertilizerRecommendations, } from '../../models' import * as openAiService from '../openAi' -export const getFertilizerSuggestions = async ({ +export const getFertilizerRecommendations = async ({ speciesId, userId, }: { speciesId: string, userId?: string, -}): Promise => { +}): Promise => { // Get species const species = await Species.findById(speciesId) @@ -27,7 +27,7 @@ export const getFertilizerSuggestions = async ({ }) } - const speciesSuggestions = species.toObject().fertilizerSuggestions + const speciesFertilizerRecommendations = species.toObject().fertilizerRecommendations // Check if SpeciesUser record already exists const speciesUser = await SpeciesUser.findOne({ @@ -37,9 +37,9 @@ export const getFertilizerSuggestions = async ({ .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') // If we already have both species-level suggestions and user-level fertilizer recommendations (if userId was passed), return the existing data - if (speciesSuggestions && (!userId || speciesUser?.recommendedFertilizers)) { + if (speciesFertilizerRecommendations && (!userId || speciesUser?.recommendedFertilizers)) { const existingData = { - ...speciesSuggestions, + ...speciesFertilizerRecommendations, recommendedUserFertilizers: speciesUser?.recommendedFertilizers || [], } @@ -78,7 +78,7 @@ export const getFertilizerSuggestions = async ({ recommendedProducts: ['Brand Name - Product Name (N-P-K)', 'Brand Name - Product Name (N-P-K)'], } - if (!speciesSuggestions?.text) { + if (!speciesFertilizerRecommendations?.text) { promptInstructionsSpec.push('A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant.') } @@ -99,7 +99,7 @@ export const getFertilizerSuggestions = async ({ promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") - if (!speciesSuggestions?.recommendedProducts.length) { + if (!speciesFertilizerRecommendations?.recommendedProducts.length) { promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") } @@ -107,7 +107,7 @@ export const getFertilizerSuggestions = async ({ responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] } else { - if (!speciesSuggestions?.recommendedProducts.length) { + if (!speciesFertilizerRecommendations?.recommendedProducts.length) { promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") } } @@ -175,18 +175,18 @@ ${promptSuffix} } catch (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: `Failed to get fertilizer suggestions from OpenAI: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: `Failed to get fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, }) } - const newSpeciesSuggestions: ISpeciesFertilizerSuggestions | null = (text && recommendedProducts) ? { + const newSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations | null = (text && recommendedProducts) ? { text, recommendedProducts, } : null // Store species-level suggestions (text and recommendedProducts) on Species if they do not already exist - if (!species.fertilizerSuggestions) { - species.fertilizerSuggestions = newSpeciesSuggestions + if (!species.fertilizerRecommendations) { + species.fertilizerRecommendations = newSpeciesFertilizerRecommendations await species.save() } @@ -205,9 +205,9 @@ ${promptSuffix} const newSpeciesUserPopulated = await SpeciesUser.findById(newSpeciesUser._id) .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') - const response: ISpeciesFertilizerSuggestions & { recommendedUserFertilizers: IFertilizer[] } = { - text: (newSpeciesSuggestions || speciesSuggestions)?.text || '', - recommendedProducts: (newSpeciesSuggestions || speciesSuggestions)?.recommendedProducts || [], + const response: ISpeciesFertilizerRecommendations & { recommendedUserFertilizers: IFertilizer[] } = { + text: (newSpeciesFertilizerRecommendations || speciesFertilizerRecommendations)?.text || '', + recommendedProducts: (newSpeciesFertilizerRecommendations || speciesFertilizerRecommendations)?.recommendedProducts || [], recommendedUserFertilizers: (newSpeciesUserPopulated?.toObject() || speciesUser?.toObject())?.recommendedFertilizers || [], } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 08b0f06..e0d226a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@plannting/mobile", - "version": "0.8.0", + "version": "0.9.0", "main": "index.ts", "scripts": { "dev": "ts-node ../../scripts/writeAdditionalEnvVars.ts --expo -- expo start --clear", diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index e8836f6..27aacc9 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -1,13 +1,12 @@ import React from 'react' -import { ActivityIndicator, Image, Modal, ScrollView, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native' +import { Image, Modal, ScrollView, Switch, Text, TouchableOpacity, View } from 'react-native' import { useLayoutEffect } from 'react' import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' import type { ISpecies } from '@plannting/api/dist/models/Species' -import { AddEditChoreModal } from '../../components/AddEditChoreModal' -import { BarChart } from '../../components/BarChart' +import { AddEditPlantModal } from '../../components/AddEditPlantModal' import { DateTimePicker } from '../../components/DateTimePicker' import { ExpandableArrow } from '../../components/ExpandableArrow' import { Fab } from '../../components/Fab' @@ -16,7 +15,6 @@ import { IconFilter } from '../../components/IconFilter' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' -import { SegmentedControl } from '../../components/SegmentedControl' import { SwipeToDelete } from '../../components/SwipeToDelete' import { useAlert } from '../../contexts/AlertContext' @@ -24,10 +22,9 @@ import { useAlert } from '../../contexts/AlertContext' import { useDebounce } from '../../hooks/useDebounce' import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' -import { trpc, type Endpoints } from '../../trpc' +import { trpc } from '../../trpc' -import { histogramDataFromChoreLogs, type HistogramChoreLog } from '../../utils/histogram' -import { formatLifecycleWithIcon, LIFECYCLE_ICONS, type PlantLifecycle } from '../../utils/lifecycle' +import { formatLifecycleWithIcon, type PlantLifecycle } from '../../utils/lifecycle' import { palette, styles } from '../../styles' @@ -69,39 +66,16 @@ export function PlantsScreen() { const { alert } = useAlert() const { expandPlantId } = useLocalSearchParams<{ expandPlantId?: string }>() - - const contentContainerY = React.useRef(0) - const hasScrolledToPlant = React.useRef(false) - const plantPositions = React.useRef>(new Map()) const scrollViewRef = React.useRef(null) - - const [expandedIds, setExpandedIds] = React.useState>(new Set()) const [showAddForm, setShowAddForm] = React.useState(false) const [editingId, setEditingId] = React.useState(null) const [originalLifecycle, setOriginalLifecycle] = React.useState(null) - const [showChoreForm, setShowChoreForm] = React.useState(null) // plantId const [showAddFormPlantedAtDatePicker, setShowAddFormPlantedAtDatePicker] = React.useState(false) const [showEditFormPlantedAtDatePicker, setShowEditFormPlantedAtDatePicker] = React.useState(false) const [showLifecycleChangeDateModal, setShowLifecycleChangeDateModal] = React.useState(false) const [lifecycleChangeDate, setLifecycleChangeDate] = React.useState(null) const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = React.useState(false) const [pendingLifecycleChange, setPendingLifecycleChange] = React.useState<{ plantId: string, data: EditFormData } | null>(null) - - const [choreFormData, setChoreFormData] = React.useState<{ - description: string, - lifecycles: PlantLifecycle[], - fertilizers: Array<{ id: string, amount: string }>, - recurAmount: string, - recurUnit: 'day' | 'week' | 'year' | '', - notes: string, - }>({ - description: '', - lifecycles: [], - fertilizers: [], - recurAmount: '', - recurUnit: '', - notes: '', - }) const [addFormData, setAddFormData] = React.useState(initialAddFormData) const [editFormData, setEditFormData] = React.useState(initialEditFormData) const [editingPlantSpecies, setEditingPlantSpecies] = React.useState(null) @@ -144,17 +118,8 @@ export function PlantsScreen() { retry: 1, }) - // Fetch fertilizers for chore form - const { - data: fertilizersData, - refetch: refetchFertilizers, - } = trpc.fertilizers.list.useQuery({ q: ''}, { - retry: 1, - }) - useRefreshOnFocus(() => { refetch() - refetchFertilizers() }) const createMutation = trpc.plants.create.useMutation({ @@ -205,21 +170,6 @@ export function PlantsScreen() { }, }) - const createChoreMutation = trpc.chores.create.useMutation({ - onSuccess: () => { - refetch() - setShowChoreForm(null) - setChoreFormData({ - description: '', - lifecycles: [], - fertilizers: [], - recurAmount: '', - recurUnit: '', - notes: '', - }) - }, - }) - const handleAddSubmit = () => { createMutation.mutate({ name: addFormData.name, @@ -369,91 +319,14 @@ export function PlantsScreen() { ) } - const toggleExpand = (plantId: string) => { - setExpandedIds(prev => { - const newSet = new Set(prev) - if (newSet.has(plantId)) { - newSet.delete(plantId) - } else { - newSet.add(plantId) - } - return newSet - }) - } - - const handleChoreSubmit = (plantId: string) => { - createChoreMutation.mutate({ - plantId, - description: choreFormData.description || undefined, - lifecycles: choreFormData.lifecycles.length > 0 ? choreFormData.lifecycles : undefined, - fertilizers: choreFormData.fertilizers.length > 0 ? choreFormData.fertilizers : undefined, - recurAmount: choreFormData.recurAmount ? parseFloat(choreFormData.recurAmount) : undefined, - recurUnit: choreFormData.recurUnit || undefined, - notes: choreFormData.notes || undefined, - }) - } - - const scrollTo = React.useCallback((y: number) => { - if (!scrollViewRef.current) { - return - } - - const offset = -20 - - scrollViewRef.current.scrollTo({ y: Math.max(0, y + offset + (contentContainerY.current || 0)), animated: true }) - }, []) - - // Reset scroll flag when expandPlantId changes - React.useEffect(() => { - hasScrolledToPlant.current = false - plantPositions.current.clear() - }, [expandPlantId]) - - // Handle expandPlantId parameter - expand plant when data is loaded + // Backwards-compat deep link support: /(tabs)/plants?expandPlantId= React.useEffect(() => { - if (expandPlantId && data?.plants && !isLoading && !hasScrolledToPlant.current) { - // Check if plant exists - const plantExists = data.plants.some(p => p._id === expandPlantId) - if (!plantExists) return + if (!expandPlantId) return + if (isLoading) return + if (!data?.plants?.some(p => p._id === expandPlantId)) return - // Expand the plant - setExpandedIds(prev => { - const newSet = new Set(prev) - newSet.add(expandPlantId) - return newSet - }) - } - }, [expandPlantId, data?.plants, isLoading]) - - // Handle scrolling after expansion and layout - React.useEffect(() => { - if (expandPlantId && expandedIds.has(expandPlantId) && scrollViewRef.current && !isLoading && !hasScrolledToPlant.current) { - // Wait for the next frame to ensure layout is complete after expansion - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const plantY = plantPositions.current.get(expandPlantId) - - if (plantY !== undefined && scrollViewRef.current) { - // Scroll to the plant position with offset - scrollTo(plantY) - } else { - // Retry if position not yet measured (layout might not have fired yet) - const timeoutId = setTimeout(() => { - const retryY = plantPositions.current.get(expandPlantId) - if (retryY !== undefined && scrollViewRef.current) { - scrollTo(retryY) - } - }, 300) - - return () => clearTimeout(timeoutId) - } - }) - }) - - // Mark that we've initiated the process - hasScrolledToPlant.current = true - } - }, [expandPlantId, expandedIds, hasScrolledToPlant.current, isLoading]) + router.push(`/plants/${expandPlantId}`) + }, [data?.plants, expandPlantId, isLoading, router]) return ( @@ -464,13 +337,7 @@ export function PlantsScreen() { Plants - { - // Store the Y position of the content container - const { y } = event.nativeEvent.layout - contentContainerY.current = y - }} - > + {(isLoading && ( ( @@ -521,30 +388,6 @@ export function PlantsScreen() { No plants found. ) : ( <> - { - setShowChoreForm(null) - setChoreFormData({ - description: '', - lifecycles: [], - fertilizers: [], - recurAmount: '', - recurUnit: '', - notes: '', - }) - }} - onSubmit={() => { - if (showChoreForm) { - handleChoreSubmit(showChoreForm) - } - }} - mutation={createChoreMutation} - fertilizersData={fertilizersData} - /> - {data?.plants?.map((plant, index) => { - const isExpanded = expandedIds.has(plant._id) - + {data?.plants?.map((plant) => { return ( { - // Get the Y position relative to the ScrollView's content container - const { y } = event.nativeEvent.layout - - // Store the Y position (relative to content container) - plantPositions.current.set(plant._id, y) - - // If this is the plant we need to scroll to and it's expanded, and we haven't already scrolled to it, trigger scroll - if (expandPlantId !== plant._id || !expandedIds.has(plant._id) || !scrollViewRef.current || hasScrolledToPlant.current) { - return - } - - // Use requestAnimationFrame to ensure layout is complete - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const currentY = plantPositions.current.get(plant._id) - if (currentY !== undefined && scrollViewRef.current) { - scrollTo(currentY) - } - }) - }) - }} > handleArchiveClick(plant)} onDelete={plant.deletedAt ? () => handleDeleteClick(plant) : undefined} onRestore={plant.deletedAt ? () => handleRestoreClick(plant) : undefined} - disabled={isExpanded} > toggleExpand(plant._id)} + onPress={() => router.push(`/plants/${plant._id}`)} activeOpacity={0.7} > - + {plant.image && ( - {isExpanded && ( - - - - {plant.plantedAt && ( - - Planted on {new Date(plant.plantedAt).toLocaleDateString('en-US')} - - )} - {plant.notes && ( - - Notes: {plant.notes} - - )} - - - - handleEditClick(plant)} - > - ✏️ - - - - - {plant.species && } - - - - - - Chores - { - setShowChoreForm(plant._id) - setChoreFormData({ - description: '', - lifecycles: [], - fertilizers: [], - recurAmount: '', - recurUnit: '', - notes: '', - }) - }} - > - + Add - - - - - {!plant.chores.length ? ( - No chores found. - ) : plant.chores.map((chore, index) => { - return ( - router.push(`/chores/${chore._id}`)} - activeOpacity={0.7} - > - - - {chore.fertilizers && chore.fertilizers.length > 0 - ? chore.fertilizers.map(f => { - const name = typeof f.fertilizer === 'object' && f.fertilizer?.name ? f.fertilizer.name : '' - return `${name}${f.amount ? ` (${f.amount})` : ''}` - }).join(', ') - : chore.description || 'Unknown'} - {chore.recurAmount ? ` every ${chore.recurAmount} ${chore.recurUnit}${chore.recurAmount === 1 ? '' : 's'}` : ''} - - - ) - })} - - )} @@ -798,534 +533,4 @@ export function PlantsScreen() { ) } - -function AddEditPlantModal({ - formData, - isVisible, - mode, - mutation, - onChange, - onClose, - onSubmit, - setShowDatePicker, - showDatePicker, - initialSpecies, -}: { - formData: { - name: string, - plantedAt: Date | null, - lifecycle: PlantLifecycle | '', - notes: string, - speciesId: string | null, - }, - isVisible: boolean, - mode: 'add' | 'edit', - mutation: { - error: { message: string } | null, - isPending: boolean, - }, - onChange: (data: typeof formData) => void, - onClose: () => void, - onSubmit: () => void, - setShowDatePicker: (show: boolean) => void, - showDatePicker: boolean, - initialSpecies?: ISpecies | null, -}) { - const [showSpeciesSuggestions, setShowSpeciesSuggestions] = React.useState(false) - const [selectedSpecies, setSelectedSpecies] = React.useState(null) - const [showNameInput, setShowNameInput] = React.useState(false) - const [showSpeciesInput, setShowSpeciesInput] = React.useState(false) - const [speciesSearchQuery, setSpeciesSearchQuery] = React.useState('') - - const { data: speciesData, isLoading: isLoadingSpecies } = trpc.species.list.useQuery( - { q: speciesSearchQuery || undefined }, - { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } - ) - - // Fetch fertilizer suggestions when a species is selected (only in add mode) - const { data: fertilizerSuggestionsData, isLoading: isLoadingFertilizerSuggestions } = trpc.species.getFertilizerSuggestions.useQuery( - { speciesId: selectedSpecies?._id ?? '' }, - { enabled: mode === 'add' && !!selectedSpecies?._id } - ) - - // When the modal is opened or closed, set the default form state - React.useEffect(() => { - if (!isVisible) { - setShowNameInput(false) - setShowSpeciesSuggestions(false) - setSelectedSpecies(null) - setShowSpeciesInput(false) - setSpeciesSearchQuery('') - - return - } - - if (mode === 'add') { - setShowNameInput(false) - } - else { - setShowNameInput(true) - } - }, [ - isVisible, - mode, - ]) - - // When the modal is opened or the species changes, update the form state - React.useEffect(() => { - if (!isVisible) { - return - } - - if (formData.speciesId && initialSpecies && initialSpecies._id === formData.speciesId) { - // When form opens with a species, set selectedSpecies from initialSpecies - setSelectedSpecies(initialSpecies) - setShowSpeciesInput(false) - } else if (!formData.speciesId) { - // If no species is set, show the species input - setShowSpeciesInput(true) - } - }, [ - formData.speciesId, - initialSpecies, - isVisible, - ]) - - const handleSpeciesSelect = (species: NonNullable['species'][0]) => { - // Update speciesId and set name to commonName if name is empty - const newName = formData.name.trim() === '' ? species.commonName : formData.name - onChange({ ...formData, name: newName, speciesId: species._id }) - setShowNameInput(true) - - setShowSpeciesSuggestions(false) - setSpeciesSearchQuery('') - setSelectedSpecies(species) - setShowSpeciesInput(false) - } - - const handleSpeciesInputBlur = () => { - // Ignore blur event when the autosuggest is open, to prevent closing the autosuggest - if (showSpeciesSuggestions) { - return - } - - // If name is empty and we have a selected species, set name to commonName - if (formData.name.trim() === '') { - onChange({ ...formData, name: speciesSearchQuery }) - - setShowNameInput(true) - } - - setShowSpeciesSuggestions(false) - setSpeciesSearchQuery('') - } - - const handleClearSpecies = () => { - onChange({ ...formData, speciesId: null }) - setSelectedSpecies(null) - setShowSpeciesInput(true) - if (formData.name.trim() === '') { - setShowNameInput(false) - } - setSpeciesSearchQuery('') - } - - return ( - - - - - {mode === 'add' ? 'Add New Plant' : 'Edit Plant'} - - - - - - - { - if (showSpeciesSuggestions) { - setShowSpeciesSuggestions(false) - } - }} - style={[styles.formContainer, { flex: 1 }]} - > - {showNameInput && ( - <> - Name - onChange({ ...formData, name: text })} - /> - - )} - {(selectedSpecies && !showSpeciesInput && ( - <> - Species - - {mode === 'add' && ( - <> - Fertilizer Suggestions - {isLoadingFertilizerSuggestions ? ( - - - - Getting fertilizer recommendations... - - - ) : fertilizerSuggestionsData?.text ? ( - - - {fertilizerSuggestionsData.text} - - - {fertilizerSuggestionsData.recommendedUserFertilizers && fertilizerSuggestionsData.recommendedUserFertilizers.length > 0 && ( - <> - - Recommended from Your Inventory - - {fertilizerSuggestionsData.recommendedUserFertilizers.map((fertilizer, index) => ( - - • {fertilizer.name} ({fertilizer.nitrogen !== null ? fertilizer.nitrogen : null}-{fertilizer.phosphorus !== null ? fertilizer.phosphorus : null}-{fertilizer.potassium !== null ? fertilizer.potassium : null}) - - ))} - - )} - - {fertilizerSuggestionsData.recommendedProducts && fertilizerSuggestionsData.recommendedProducts.length > 0 && ( - <> - - Other Recommended Products - - {fertilizerSuggestionsData.recommendedProducts.map((product, index) => ( - - • {product} - - ))} - - )} - - ) : null} - - )} - - )) || ( - <> - Species - - { - setSpeciesSearchQuery(text) - setShowSpeciesSuggestions(true) - }} - onFocus={() => setShowSpeciesSuggestions(true)} - onBlur={handleSpeciesInputBlur} - /> - {isLoadingSpecies && ( - - - - )} - {showSpeciesSuggestions && speciesData?.species && speciesData.species.length > 0 && ( - - { - e.stopPropagation() - e.preventDefault() - }} - > - {speciesData.species.map((item) => ( - handleSpeciesSelect(item)} - > - {item.imageUrl && ( - - )} - - {item.commonName} - {item.scientificName && ( - {item.scientificName} - )} - {(item.family || item.genus) && ( - - {[item.genus, item.family].filter(Boolean).join(' • ')} - - )} - - - ))} - - - )} - - - )} - - Planted On - { - if (selectedDate) { - onChange({ ...formData, plantedAt: selectedDate }) - } - }} - setShowPicker={setShowDatePicker} - showPicker={showDatePicker} - value={formData.plantedAt} - /> - - Current Lifecycle - onChange({ ...formData, lifecycle: value.toLowerCase() as PlantLifecycle })} - icons={{ - 'Start': LIFECYCLE_ICONS.start, - 'Veg': LIFECYCLE_ICONS.veg, - 'Bloom': LIFECYCLE_ICONS.bloom, - 'Fruiting': LIFECYCLE_ICONS.fruiting, - }} - /> - - Notes (optional) - onChange({ ...formData, notes: text })} - multiline - numberOfLines={3} - /> - - {mutation.error && ( - - Error: {mutation.error.message} - - )} - - - - {mutation.isPending ? 'Saving...' : 'Save'} - - - - - Cancel - - - - - - - ) -} - -function PlantHistogram({ - plant, -}: { - plant: Endpoints['plants']['list']['plants'][number], -}) { - const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Month') - - // Fetch lifecycle events for this plant - const { - data: lifecycleEventsData, - } = trpc.plants.listLifecycleEvents.useQuery( - { id: plant._id }, - { retry: 1 } - ) - - // Get bar chart data from all chore logs for this plant - // Include logs from all chores, including those with deletedAt values - const histogramData = React.useMemo(() => { - if (!plant.chores || plant.chores.length === 0) return [] - - // Collect all logs from all chores (including deleted chores) - const allLogs: Array = [] - plant.chores.forEach(chore => { - // Include logs from all chores, regardless of deletedAt status - if (chore.logs && Array.isArray(chore.logs)) { - chore.logs.forEach(log => { - if (log.doneAt) { - const fertilizers = (log.fertilizers && log.fertilizers.length && log.fertilizers) - || (chore.fertilizers && chore.fertilizers.length && chore.fertilizers) - || [] - - const title = (fertilizers && fertilizers.length > 0 - ? log.fertilizers.map(f => { - const fertObj = f.fertilizer as any - const name = fertObj && typeof fertObj === 'object' && fertObj.name ? fertObj.name : '' - - return `${name}${f.amount ? ` (${f.amount})` : ''}` - }).join(', ') - : chore.description || 'Chore') - - const description = log.notes - - allLogs.push({ - ...log, - description, - title, - action: log.action, - snoozeUntil: log.snoozeUntil, - }) - } - }) - } - }) - - return histogramDataFromChoreLogs(allLogs) - }, [plant.chores]) - - const histogramEvents = React.useMemo(() => { - return [ - { - date: plant.plantedAt ? new Date(plant.plantedAt) : undefined, - label: 'Planted', - }, - ...(lifecycleEventsData?.events || []).map(event => ({ - date: event.date ? new Date(event.date) : undefined, - label: event.toLifecycle, - })), - ] - }, [lifecycleEventsData?.events, plant.plantedAt]) - - return ( - - - History - - - setTimeRange(value)} - /> - - - - - - ) -} - -function SpeciesCard({ - onClose = undefined, - species, -}: { - onClose?: () => void, - species: ISpecies, -}) { - return ( - - {species.imageUrl && ( - - )} - - {species.commonName} - {species.scientificName && ( - {species.scientificName} - )} - - - {onClose && ( - - - - )} - - - ) -} - export default PlantsScreen diff --git a/apps/mobile/src/app/chores/[id].tsx b/apps/mobile/src/app/chores/[id].tsx index 5859383..19b79c6 100644 --- a/apps/mobile/src/app/chores/[id].tsx +++ b/apps/mobile/src/app/chores/[id].tsx @@ -342,7 +342,7 @@ export default function ChoreDetailScreen() { Plant:{' '} {chore.plant?._id ? ( router.push(`/(tabs)/plants?expandPlantId=${chore.plant?._id}`)} + onPress={() => router.push(`/plants/${chore.plant?._id}`)} activeOpacity={0.7} style={{ margin: 4 }} > diff --git a/apps/mobile/src/app/chores/_layout.tsx b/apps/mobile/src/app/chores/_layout.tsx index bba8c09..3608e92 100644 --- a/apps/mobile/src/app/chores/_layout.tsx +++ b/apps/mobile/src/app/chores/_layout.tsx @@ -17,4 +17,3 @@ export default function ChoresLayout() { ) } - diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx new file mode 100644 index 0000000..c337c92 --- /dev/null +++ b/apps/mobile/src/app/plants/[id].tsx @@ -0,0 +1,460 @@ +import React, { useEffect } from 'react' +import { ActivityIndicator, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' +import { keepPreviousData } from '@tanstack/react-query' + +import type { ISpecies } from '@plannting/api/dist/models/Species' + +import { AddEditChoreModal } from '../../components/AddEditChoreModal' +import { AddEditPlantModal } from '../../components/AddEditPlantModal' +import { DateTimePicker } from '../../components/DateTimePicker' +import { FertilizerRecommendations } from '../../components/FertilizerRecommendations' +import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' +import { PlantHistogram } from '../../components/PlantHistogram' +import { ScreenTitle } from '../../components/ScreenTitle' +import { ScreenWrapper } from '../../components/ScreenWrapper' +import { SpeciesCard } from '../../components/SpeciesCard' + +import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' + +import { trpc } from '../../trpc' + +import { formatLifecycleWithIcon, type PlantLifecycle } from '../../utils/lifecycle' + +import { palette, styles } from '../../styles' + +type EditFormData = { + name: string, + plantedAt: Date | null, + lifecycle: PlantLifecycle | '', + notes: string, + speciesId: string | null, +} + +const initialEditFormData: EditFormData = { + name: '', + plantedAt: null, + lifecycle: 'start', + notes: '', + speciesId: null, +} + +export default function PlantDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + const router = useRouter() + const navigation = useNavigation() + + const [editingId, setEditingId] = React.useState(null) + const [originalLifecycle, setOriginalLifecycle] = React.useState(null) + const [showEditFormPlantedAtDatePicker, setShowEditFormPlantedAtDatePicker] = React.useState(false) + const [editFormData, setEditFormData] = React.useState(initialEditFormData) + const [editingPlantSpecies, setEditingPlantSpecies] = React.useState(null) + + const [showLifecycleChangeDateModal, setShowLifecycleChangeDateModal] = React.useState(false) + const [lifecycleChangeDate, setLifecycleChangeDate] = React.useState(null) + const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = React.useState(false) + const [pendingLifecycleChange, setPendingLifecycleChange] = React.useState<{ plantId: string, data: EditFormData } | null>(null) + + const [showChoreForm, setShowChoreForm] = React.useState(false) + const [choreFormData, setChoreFormData] = React.useState<{ + description: string, + lifecycles: PlantLifecycle[], + fertilizers: Array<{ id: string, amount: string }>, + recurAmount: string, + recurUnit: 'day' | 'week' | 'year' | '', + notes: string, + }>({ + description: '', + lifecycles: [], + fertilizers: [], + recurAmount: '', + recurUnit: '', + notes: '', + }) + + const { + data, + isLoading, + error, + isError, + isRefetching, + refetch, + } = trpc.plants.list.useQuery({ + includeDeletedItems: true, + q: '', + sortBy: [ + { field: 'name', direction: 'asc' }, + ], + }, { + placeholderData: keepPreviousData, + refetchInterval: 30000, + retry: 1, + }) + + const plant = data?.plants?.find(p => p._id === id) + + useEffect(() => { + const title = plant?.name || '...' + navigation.getParent()?.setOptions({ title }) + }, [navigation, plant?.name]) + + const resetEditForm = () => { + setEditingId(null) + setOriginalLifecycle(null) + setEditFormData(initialEditFormData) + setShowEditFormPlantedAtDatePicker(false) + setEditingPlantSpecies(null) + } + + const updateMutation = trpc.plants.update.useMutation({ + onSuccess: () => { + refetch() + resetEditForm() + }, + }) + + // Fetch fertilizers for chore form + const { + data: fertilizersData, + refetch: refetchFertilizers, + } = trpc.fertilizers.list.useQuery({ q: ''}, { + retry: 1, + }) + + const createChoreMutation = trpc.chores.create.useMutation({ + onSuccess: () => { + refetch() + setShowChoreForm(false) + setChoreFormData({ + description: '', + lifecycles: [], + fertilizers: [], + recurAmount: '', + recurUnit: '', + notes: '', + }) + }, + }) + + useRefreshOnFocus(() => { + refetch() + refetchFertilizers() + }) + + const updatePlant = (plantId: string, dataToSave: EditFormData, lifecycleChangeDateOverride?: Date | null) => { + updateMutation.mutate({ + id: plantId, + name: dataToSave.name, + plantedAt: dataToSave.plantedAt ?? undefined, + lifecycle: dataToSave.lifecycle || undefined, + lifecycleChangeDate: lifecycleChangeDateOverride ?? undefined, + notes: dataToSave.notes || undefined, + speciesId: dataToSave.speciesId ?? null, + }) + } + + const handleEditClick = () => { + if (!plant) return + + setEditingId(plant._id) + setShowEditFormPlantedAtDatePicker(false) + + const localDate = plant.plantedAt ? new Date(plant.plantedAt) : new Date() + const lifecycle = plant.lifecycle || 'start' + setOriginalLifecycle(lifecycle) + setEditFormData({ + name: plant.name, + plantedAt: localDate, + lifecycle, + notes: plant.notes || '', + speciesId: plant.species?._id?.toString() || null, + }) + + if (plant.species && typeof plant.species === 'object' && '_id' in plant.species && 'commonName' in plant.species) { + setEditingPlantSpecies(plant.species) + } else { + setEditingPlantSpecies(null) + } + } + + const handleEditSubmit = () => { + if (!editingId) return + + const lifecycleChanged = originalLifecycle !== editFormData.lifecycle + if (lifecycleChanged) { + const plantId = editingId + const formData = { ...editFormData } + resetEditForm() + setPendingLifecycleChange({ plantId, data: formData }) + setLifecycleChangeDate(new Date()) + setShowLifecycleChangeDatePicker(false) + setShowLifecycleChangeDateModal(true) + return + } + + updatePlant(editingId, editFormData) + } + + const handleLifecycleChangeDateConfirm = () => { + if (!pendingLifecycleChange || !lifecycleChangeDate) return + + updatePlant(pendingLifecycleChange.plantId, pendingLifecycleChange.data, lifecycleChangeDate) + setShowLifecycleChangeDateModal(false) + setPendingLifecycleChange(null) + setLifecycleChangeDate(null) + setShowLifecycleChangeDatePicker(false) + } + + const handleLifecycleChangeDateCancel = () => { + setShowLifecycleChangeDateModal(false) + setPendingLifecycleChange(null) + setLifecycleChangeDate(null) + setShowLifecycleChangeDatePicker(false) + } + + const handleChoreSubmit = () => { + if (!plant?._id) return + + createChoreMutation.mutate({ + plantId: plant._id, + description: choreFormData.description || undefined, + lifecycles: choreFormData.lifecycles.length > 0 ? choreFormData.lifecycles : undefined, + fertilizers: choreFormData.fertilizers.length > 0 ? choreFormData.fertilizers : undefined, + recurAmount: choreFormData.recurAmount ? parseFloat(choreFormData.recurAmount) : undefined, + recurUnit: choreFormData.recurUnit || undefined, + notes: choreFormData.notes || undefined, + }) + } + + return ( + + + {plant?.name || } + + + {(isLoading && ( + ( + + + + + + + + + )} + /> + )) || (isError && ( + + + Error: {error?.message || 'Unknown error'} + + refetch()} + > + Retry + + + )) || (plant && ( + + + + + + + + Changed to {pendingLifecycleChange?.data.lifecycle ? formatLifecycleWithIcon(pendingLifecycleChange.data.lifecycle) : ''} on + + + + + + + Date + { + if (selectedDate) { + setLifecycleChangeDate(selectedDate) + setShowLifecycleChangeDatePicker(false) + } + }} + setShowPicker={setShowLifecycleChangeDatePicker} + showPicker={showLifecycleChangeDatePicker} + value={lifecycleChangeDate} + /> + + + {updateMutation.isPending ? 'Saving...' : 'Save'} + + + + + Cancel + + + + + + + { + setShowChoreForm(false) + setChoreFormData({ + description: '', + lifecycles: [], + fertilizers: [], + recurAmount: '', + recurUnit: '', + notes: '', + }) + }} + onSubmit={handleChoreSubmit} + mutation={createChoreMutation} + fertilizersData={fertilizersData} + /> + + + + {plant.lifecycle && ( + + {formatLifecycleWithIcon(plant.lifecycle)} + + )} + {plant.plantedAt && ( + + Planted on {new Date(plant.plantedAt).toLocaleDateString('en-US')} + + )} + {plant.notes && ( + + Notes: {plant.notes} + + )} + + + + ✏️ + + + + + {plant.species && } + + + History + + + + + Fertilizer Recommendations + + {plant.species && || Specify a species to see recommendations.} + + + + Chores + { + setShowChoreForm(true) + setChoreFormData({ + description: '', + lifecycles: [], + fertilizers: [], + recurAmount: '', + recurUnit: '', + notes: '', + }) + }} + > + + Add + + + + {!plant.chores.length ? ( + No chores found. + ) : plant.chores.map((chore) => { + return ( + router.push(`/chores/${chore._id}`)} + activeOpacity={0.7} + > + + + {chore.fertilizers && chore.fertilizers.length > 0 + ? chore.fertilizers.map(f => { + const name = typeof f.fertilizer === 'object' && f.fertilizer?.name ? f.fertilizer.name : '' + return `${name}${f.amount ? ` (${f.amount})` : ''}` + }).join(', ') + : chore.description || 'Unknown'} + {chore.recurAmount ? ` every ${chore.recurAmount} ${chore.recurUnit}${chore.recurAmount === 1 ? '' : 's'}` : ''} + + + ) + })} + + )) || ( + + Plant not found + router.back()} + > + Go Back + + + )} + + ) +} + +const localStyles = StyleSheet.create({ + noDataText: { + fontSize: 14, + color: palette.textSecondary, + fontStyle: 'italic', + }, +}) diff --git a/apps/mobile/src/app/plants/_layout.tsx b/apps/mobile/src/app/plants/_layout.tsx new file mode 100644 index 0000000..1ec84c7 --- /dev/null +++ b/apps/mobile/src/app/plants/_layout.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { Stack } from 'expo-router' + +export default function PlantsLayout() { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/AddEditPlantModal.tsx b/apps/mobile/src/components/AddEditPlantModal.tsx new file mode 100644 index 0000000..f40d550 --- /dev/null +++ b/apps/mobile/src/components/AddEditPlantModal.tsx @@ -0,0 +1,361 @@ +import React from 'react' +import { ActivityIndicator, Image, Modal, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native' + +import type { ISpecies } from '@plannting/api/dist/models/Species' + +import { DateTimePicker } from './DateTimePicker' +import { FertilizerRecommendations } from './FertilizerRecommendations' +import { SegmentedControl } from './SegmentedControl' +import { SpeciesCard } from './SpeciesCard' + +import { trpc } from '../trpc' + +import { LIFECYCLE_ICONS, type PlantLifecycle } from '../utils/lifecycle' + +import { palette, styles } from '../styles' + +export function AddEditPlantModal({ + formData, + isVisible, + mode, + mutation, + onChange, + onClose, + onSubmit, + setShowDatePicker, + showDatePicker, + initialSpecies, +}: { + formData: { + name: string, + plantedAt: Date | null, + lifecycle: PlantLifecycle | '', + notes: string, + speciesId: string | null, + }, + isVisible: boolean, + mode: 'add' | 'edit', + mutation: { + error: { message: string } | null, + isPending: boolean, + }, + onChange: (data: { + name: string, + plantedAt: Date | null, + lifecycle: PlantLifecycle | '', + notes: string, + speciesId: string | null, + }) => void, + onClose: () => void, + onSubmit: () => void, + setShowDatePicker: (show: boolean) => void, + showDatePicker: boolean, + initialSpecies?: ISpecies | null, +}) { + const [showSpeciesSuggestions, setShowSpeciesSuggestions] = React.useState(false) + const [selectedSpecies, setSelectedSpecies] = React.useState(null) + const [showNameInput, setShowNameInput] = React.useState(false) + const [showSpeciesInput, setShowSpeciesInput] = React.useState(false) + const [speciesSearchQuery, setSpeciesSearchQuery] = React.useState('') + + const { data: speciesData, isLoading: isLoadingSpecies } = trpc.species.list.useQuery( + { q: speciesSearchQuery || undefined }, + { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } + ) + + // When the modal is opened or closed, set the default form state + React.useEffect(() => { + if (!isVisible) { + setShowNameInput(false) + setShowSpeciesSuggestions(false) + setSelectedSpecies(null) + setShowSpeciesInput(false) + setSpeciesSearchQuery('') + + return + } + + if (mode === 'add') { + setShowNameInput(false) + } + else { + setShowNameInput(true) + } + }, [ + isVisible, + mode, + ]) + + // When the modal is opened or the species changes, update the form state + React.useEffect(() => { + if (!isVisible) { + return + } + + if (formData.speciesId && initialSpecies && initialSpecies._id === formData.speciesId) { + // When form opens with a species, set selectedSpecies from initialSpecies + setSelectedSpecies(initialSpecies) + setShowSpeciesInput(false) + } else if (!formData.speciesId) { + // If no species is set, show the species input + setShowSpeciesInput(true) + } + }, [ + formData.speciesId, + initialSpecies, + isVisible, + ]) + + const handleSpeciesSelect = (species: NonNullable['species'][0]) => { + // Update speciesId and set name to commonName if name is empty + const newName = formData.name.trim() === '' ? species.commonName : formData.name + onChange({ ...formData, name: newName, speciesId: species._id }) + setShowNameInput(true) + + setShowSpeciesSuggestions(false) + setSpeciesSearchQuery('') + setSelectedSpecies(species) + setShowSpeciesInput(false) + } + + const handleSpeciesInputBlur = () => { + // Ignore blur event when the autosuggest is open, to prevent closing the autosuggest + if (showSpeciesSuggestions) { + return + } + + // If name is empty and we have a selected species, set name to commonName + if (formData.name.trim() === '') { + onChange({ ...formData, name: speciesSearchQuery }) + + setShowNameInput(true) + } + + setShowSpeciesSuggestions(false) + setSpeciesSearchQuery('') + } + + const handleClearSpecies = () => { + onChange({ ...formData, speciesId: null }) + setSelectedSpecies(null) + setShowSpeciesInput(true) + if (formData.name.trim() === '') { + setShowNameInput(false) + } + setSpeciesSearchQuery('') + } + + return ( + + + + + {mode === 'add' ? 'Add New Plant' : 'Edit Plant'} + + + + + + + { + if (showSpeciesSuggestions) { + setShowSpeciesSuggestions(false) + } + }} + style={[styles.formContainer, { flex: 1 }]} + > + {showNameInput && ( + <> + Name + onChange({ ...formData, name: text })} + /> + + )} + {(selectedSpecies && !showSpeciesInput && ( + <> + Species + + {mode === 'add' && ( + <> + + Fertilizer Recommendations + + + + )} + + )) || ( + <> + Species + + { + setSpeciesSearchQuery(text) + setShowSpeciesSuggestions(true) + }} + onFocus={() => setShowSpeciesSuggestions(true)} + onBlur={handleSpeciesInputBlur} + /> + {isLoadingSpecies && ( + + + + )} + {showSpeciesSuggestions && speciesData?.species && speciesData.species.length > 0 && ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + {speciesData.species.map((item) => ( + handleSpeciesSelect(item)} + > + {item.imageUrl && ( + + )} + + {item.commonName} + {item.scientificName && ( + {item.scientificName} + )} + {(item.family || item.genus) && ( + + {[item.genus, item.family].filter(Boolean).join(' • ')} + + )} + + + ))} + + + )} + + + )} + + Planted On + { + if (selectedDate) { + onChange({ ...formData, plantedAt: selectedDate }) + } + }} + setShowPicker={setShowDatePicker} + showPicker={showDatePicker} + value={formData.plantedAt} + /> + + Current Lifecycle + onChange({ ...formData, lifecycle: value.toLowerCase() as PlantLifecycle })} + icons={{ + 'Start': LIFECYCLE_ICONS.start, + 'Veg': LIFECYCLE_ICONS.veg, + 'Bloom': LIFECYCLE_ICONS.bloom, + 'Fruiting': LIFECYCLE_ICONS.fruiting, + }} + /> + + Notes (optional) + onChange({ ...formData, notes: text })} + multiline + numberOfLines={3} + /> + + {mutation.error && ( + + Error: {mutation.error.message} + + )} + + + + {mutation.isPending ? 'Saving...' : 'Save'} + + + + + Cancel + + + + + + + ) +} diff --git a/apps/mobile/src/components/FertilizerRecommendations.tsx b/apps/mobile/src/components/FertilizerRecommendations.tsx new file mode 100644 index 0000000..0a7abf2 --- /dev/null +++ b/apps/mobile/src/components/FertilizerRecommendations.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { ActivityIndicator, Text, View } from 'react-native' + +import { trpc } from '../trpc' + +import { palette, styles } from '../styles' + +export function FertilizerRecommendations({ + speciesId, + enabled = true, +}: { + speciesId: string | null | undefined, + enabled?: boolean, +}) { + const { data, isLoading } = trpc.species.getFertilizerRecommendations.useQuery( + { speciesId: speciesId ?? '' }, + { enabled: enabled && !!speciesId } + ) + + if (!enabled || !speciesId) { + return null + } + + if (isLoading) { + return ( + + + + + Getting fertilizer recommendations... + + + + ) + } + + if (!data?.text) { + return null + } + + return ( + + + + {data.text} + + + {data.recommendedUserFertilizers && data.recommendedUserFertilizers.length > 0 && ( + <> + + Recommended from Your Inventory + + {data.recommendedUserFertilizers.map((fertilizer, index) => ( + + • {fertilizer.name} ({fertilizer.nitrogen !== null ? fertilizer.nitrogen : null}-{fertilizer.phosphorus !== null ? fertilizer.phosphorus : null}-{fertilizer.potassium !== null ? fertilizer.potassium : null}) + + ))} + + )} + + {data.recommendedProducts && data.recommendedProducts.length > 0 && ( + <> + + Other Recommended Products + + {data.recommendedProducts.map((product, index) => ( + + • {product} + + ))} + + )} + + + ) +} diff --git a/apps/mobile/src/components/PlantHistogram.tsx b/apps/mobile/src/components/PlantHistogram.tsx new file mode 100644 index 0000000..f2d95d4 --- /dev/null +++ b/apps/mobile/src/components/PlantHistogram.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { Text, View } from 'react-native' + +import { BarChart } from './BarChart' +import { SegmentedControl } from './SegmentedControl' + +import { trpc, type Endpoints } from '../trpc' + +import { histogramDataFromChoreLogs, type HistogramChoreLog } from '../utils/histogram' + +import { styles } from '../styles' + +export function PlantHistogram({ + plant, +}: { + plant: Endpoints['plants']['list']['plants'][number], +}) { + const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Month') + + // Fetch lifecycle events for this plant + const { + data: lifecycleEventsData, + } = trpc.plants.listLifecycleEvents.useQuery( + { id: plant._id }, + { retry: 1 } + ) + + // Get bar chart data from all chore logs for this plant + // Include logs from all chores, including those with deletedAt values + const histogramData = React.useMemo(() => { + if (!plant.chores || plant.chores.length === 0) return [] + + // Collect all logs from all chores (including deleted chores) + const allLogs: Array = [] + plant.chores.forEach(chore => { + // Include logs from all chores, regardless of deletedAt status + if (chore.logs && Array.isArray(chore.logs)) { + chore.logs.forEach(log => { + if (log.doneAt) { + const fertilizers = (log.fertilizers && log.fertilizers.length && log.fertilizers) + || (chore.fertilizers && chore.fertilizers.length && chore.fertilizers) + || [] + + const title = (fertilizers && fertilizers.length > 0 + ? log.fertilizers.map(f => { + const fertObj = f.fertilizer as any + const name = fertObj && typeof fertObj === 'object' && fertObj.name ? fertObj.name : '' + + return `${name}${f.amount ? ` (${f.amount})` : ''}` + }).join(', ') + : chore.description || 'Chore') + + const description = log.notes + + allLogs.push({ + ...log, + description, + title, + action: log.action, + snoozeUntil: log.snoozeUntil, + }) + } + }) + } + }) + + return histogramDataFromChoreLogs(allLogs) + }, [plant.chores]) + + const histogramEvents = React.useMemo(() => { + return [ + { + date: plant.plantedAt ? new Date(plant.plantedAt) : undefined, + label: 'Planted', + }, + ...(lifecycleEventsData?.events || []).map(event => ({ + date: event.date ? new Date(event.date) : undefined, + label: event.toLifecycle, + })), + ] + }, [lifecycleEventsData?.events, plant.plantedAt]) + + return ( + + setTimeRange(value)} + /> + + + + + ) +} diff --git a/apps/mobile/src/components/SpeciesCard.tsx b/apps/mobile/src/components/SpeciesCard.tsx new file mode 100644 index 0000000..b0dde2e --- /dev/null +++ b/apps/mobile/src/components/SpeciesCard.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Image, Text, TouchableOpacity, View } from 'react-native' + +import type { ISpecies } from '@plannting/api/dist/models/Species' + +import { palette, styles } from '../styles' + +export function SpeciesCard({ + onClose = undefined, + species, +}: { + onClose?: () => void, + species: ISpecies, +}) { + return ( + + {species.imageUrl && ( + + )} + + {species.commonName} + {species.scientificName && ( + {species.scientificName} + )} + + + {onClose && ( + + + + )} + + + ) +} diff --git a/apps/mobile/src/styles.ts b/apps/mobile/src/styles.ts index 25eb28c..564fab8 100644 --- a/apps/mobile/src/styles.ts +++ b/apps/mobile/src/styles.ts @@ -61,7 +61,7 @@ export const styles = StyleSheet.create({ borderColor: palette.danger, }, subTitle: { - fontSize: 17, + fontSize: 16, fontWeight: '700', color: palette.textPrimary, }, From ab9f74eb8229d2cb29cdfe951b01a19a84847987 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 28 Jan 2026 07:20:34 -1000 Subject: [PATCH 5/8] Use onLayout to determine BarChart width --- apps/mobile/src/app/chores/[id].tsx | 3 +- apps/mobile/src/components/BarChart.tsx | 52 +++++++++++++------ .../components/FertilizerRecommendations.tsx | 38 ++++++++++---- apps/mobile/src/components/PlantHistogram.tsx | 7 +-- 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/app/chores/[id].tsx b/apps/mobile/src/app/chores/[id].tsx index 19b79c6..ebebd5d 100644 --- a/apps/mobile/src/app/chores/[id].tsx +++ b/apps/mobile/src/app/chores/[id].tsx @@ -34,7 +34,7 @@ export default function ChoreDetailScreen() { const router = useRouter() const [isEditing, setIsEditing] = React.useState(false) - const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Month') + const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Year') const [snoozeModalVisible, setSnoozeModalVisible] = React.useState(false) const [editFormData, setEditFormData] = React.useState<{ description: string, @@ -518,7 +518,6 @@ export default function ChoreDetailScreen() { diff --git a/apps/mobile/src/components/BarChart.tsx b/apps/mobile/src/components/BarChart.tsx index d82a08e..7a1f038 100644 --- a/apps/mobile/src/components/BarChart.tsx +++ b/apps/mobile/src/components/BarChart.tsx @@ -36,11 +36,10 @@ type BarChartProps = { data: BarChartInputDatum[], events?: BarChartEvent[], height?: number, - paddingHorizontal?: number, showXAxisLabels?: boolean, showYAxisLabels?: boolean, plantedAt?: Date | string | null, - timeRange?: 'Week' | 'Month' | 'Year', + timeRange: 'Week' | 'Month' | 'Year', } const BAR_SPACING = 2 @@ -52,12 +51,21 @@ export function BarChart({ data, events = [], height = 200, - paddingHorizontal = 0, showXAxisLabels = true, showYAxisLabels = true, - timeRange = 'Month', + timeRange, }: BarChartProps) { - const CHART_WIDTH = Dimensions.get('window').width - paddingHorizontal + const [measuredChartWidth, setMeasuredChartWidth] = useState(null) + + // Measures the width of the actual available chart area (after parent padding, y-axis, etc). + // Falls back to window width for the initial render before layout is measured. + const chartWidth = React.useMemo(() => { + if (measuredChartWidth && measuredChartWidth > 0) { + return measuredChartWidth + } + + return Dimensions.get('window').width + }, [measuredChartWidth]) const translateX = useRef(new Animated.Value(0)).current const gestureStartX = useRef(0) @@ -246,21 +254,29 @@ export function BarChart({ }, [data, events, timeRange, oldestDate, newestDate]) const numBarsInView = timeRange === 'Year' ? 12 : timeRange === 'Week' ? 7 : 30 - const availableWidth = CHART_WIDTH - (numBarsInView * BAR_SPACING) - const barWidth = React.useMemo(() => Math.max(availableWidth / numBarsInView, 4) - (showYAxisLabels ? 2 : 0), [numBarsInView, showYAxisLabels]) + const availableWidth = chartWidth - (BAR_SPACING * numBarsInView) + const barWidth = React.useMemo(() => Math.max(availableWidth / numBarsInView, 4) - (showYAxisLabels ? 2 : 0), [ + availableWidth, + numBarsInView, + showYAxisLabels, + ]) const maxValue = Math.max(...bars.map(d => d.value), 1) const chartHeight = height - (showXAxisLabels ? LABEL_HEIGHT : 0) - (events.length > 0 ? INFO_ICON_HEIGHT : 0) // Initialize translateX to show the most recent period React.useEffect(() => { + if (chartWidth <= 0) { + return + } + // Slide the chart to the the most recent - const initialTranslateX = getFarthestRightX(bars, barWidth, CHART_WIDTH) + const initialTranslateX = getFarthestRightX(bars, barWidth, chartWidth) translateX.setValue(initialTranslateX) gestureStartX.current = initialTranslateX - }, [bars, barWidth, timeRange]) + }, [bars, barWidth, chartWidth, translateX]) const scrollChart = (delta: number, velocity?: number) => { - const newX = calculateNewX(bars, barWidth, CHART_WIDTH, gestureStartX.current, delta) + const newX = calculateNewX(bars, barWidth, chartWidth, gestureStartX.current, delta) // If velocity is provided and significant, use momentum scrolling if (velocity !== undefined && Math.abs(velocity) > 0.5) { @@ -269,7 +285,7 @@ export function BarChart({ gestureStartX.current = newX // Calculate bounds - const overflowRightLimit = getFarthestRightX(bars, barWidth, CHART_WIDTH) + const overflowRightLimit = getFarthestRightX(bars, barWidth, chartWidth) const leftLimit = 0 // Add listener to check bounds during decay @@ -291,7 +307,7 @@ export function BarChart({ translateX.removeListener(listenerId) translateX.stopAnimation(() => { - const finalX = calculateNewX(bars, barWidth, CHART_WIDTH, boundedValue, 0) + const finalX = calculateNewX(bars, barWidth, chartWidth, boundedValue, 0) translateX.setValue(finalX) Animated.spring(translateX, { @@ -318,7 +334,7 @@ export function BarChart({ if (finished) { // Ensure we're within bounds after momentum translateX.stopAnimation((finalValue) => { - const finalX = calculateNewX(bars, barWidth, CHART_WIDTH, finalValue, 0) + const finalX = calculateNewX(bars, barWidth, chartWidth, finalValue, 0) Animated.spring(translateX, { toValue: finalX, @@ -365,7 +381,7 @@ export function BarChart({ }, onPanResponderMove: (_, gesture) => { // Provide visual feedback during swipe - const newX = calculateNewX(bars, barWidth, CHART_WIDTH, gestureStartX.current, gesture.dx, { allowOverflow: true }) + const newX = calculateNewX(bars, barWidth, chartWidth, gestureStartX.current, gesture.dx, { allowOverflow: true }) translateX.setValue(newX) }, onPanResponderRelease: (_, gesture) => { @@ -380,7 +396,7 @@ export function BarChart({ enableScroll() }, - }), [barWidth, disableScroll, enableScroll]) + }), [bars, barWidth, chartWidth, disableScroll, enableScroll]) // Calculate position for events within the view window const getEventPosition = (eventDate: Date) => { @@ -421,6 +437,12 @@ export function BarChart({ { + const nextWidth = Math.floor(e.nativeEvent.layout.width) + if (nextWidth > 0 && nextWidth !== measuredChartWidth) { + setMeasuredChartWidth(nextWidth) + } + }} {...panResponder.panHandlers} > Recommended from Your Inventory - {data.recommendedUserFertilizers.map((fertilizer, index) => ( - - • {fertilizer.name} ({fertilizer.nitrogen !== null ? fertilizer.nitrogen : null}-{fertilizer.phosphorus !== null ? fertilizer.phosphorus : null}-{fertilizer.potassium !== null ? fertilizer.potassium : null}) - - ))} + {data.recommendedUserFertilizers.map((fertilizer, index) => { + const displayName = `${fertilizer.name} (${fertilizer.nitrogen !== null ? fertilizer.nitrogen : null}-${fertilizer.phosphorus !== null ? fertilizer.phosphorus : null}-${fertilizer.potassium !== null ? fertilizer.potassium : null})` + + return ( + + + {fertilizer._id ? ( + router.push(`/(tabs)/fertilizers?expandFertilizerId=${fertilizer._id}`)} + activeOpacity={0.7} + > + + {displayName} + + + ) : ( + {displayName} + )} + + ) + })} )} @@ -74,9 +93,10 @@ export function FertilizerRecommendations({ Other Recommended Products {data.recommendedProducts.map((product, index) => ( - - • {product} - + + + {product} + ))} )} diff --git a/apps/mobile/src/components/PlantHistogram.tsx b/apps/mobile/src/components/PlantHistogram.tsx index f2d95d4..c27f3cd 100644 --- a/apps/mobile/src/components/PlantHistogram.tsx +++ b/apps/mobile/src/components/PlantHistogram.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Text, View } from 'react-native' +import { View } from 'react-native' import { BarChart } from './BarChart' import { SegmentedControl } from './SegmentedControl' @@ -8,14 +8,12 @@ import { trpc, type Endpoints } from '../trpc' import { histogramDataFromChoreLogs, type HistogramChoreLog } from '../utils/histogram' -import { styles } from '../styles' - export function PlantHistogram({ plant, }: { plant: Endpoints['plants']['list']['plants'][number], }) { - const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Month') + const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Year') // Fetch lifecycle events for this plant const { @@ -92,7 +90,6 @@ export function PlantHistogram({ data={histogramData} events={histogramEvents} height={150} - paddingHorizontal={50} showYAxisLabels={false} timeRange={timeRange} /> From 59b1ab8e7383876da388ed19f8e9cd57a9945ff8 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 28 Jan 2026 09:04:30 -1000 Subject: [PATCH 6/8] Safe parsing of AI response --- .../species/getFertilizerRecommendations.ts | 2 +- apps/api/src/models/Species.ts | 4 +- apps/api/src/services/species/index.ts | 107 ++++++++++-------- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts b/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts index fbbac2c..92d643b 100644 --- a/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts +++ b/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts @@ -17,7 +17,7 @@ export const getFertilizerRecommendations = authProcedure return { text: fertilizerRecommendations.text, - recommendedUserFertilizers: 'recommendedUserFertilizers' in fertilizerRecommendations ? fertilizerRecommendations.recommendedUserFertilizers : [], + recommendedUserFertilizers: fertilizerRecommendations.recommendedUserFertilizers, recommendedProducts: fertilizerRecommendations.recommendedProducts, } }) diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index c76e036..5b357ef 100644 --- a/apps/api/src/models/Species.ts +++ b/apps/api/src/models/Species.ts @@ -1,8 +1,8 @@ import mongoose from 'mongoose' export interface ISpeciesFertilizerRecommendations { - text: string, - recommendedProducts: string[], + text?: string, + recommendedProducts?: string[], } export interface ISpecies { diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index 46a238e..73c5bfc 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -1,4 +1,6 @@ import { TRPCError } from '@trpc/server' +import type OpenAI from 'openai' +import { z } from 'zod' import { Fertilizer, @@ -10,13 +12,21 @@ import { import * as openAiService from '../openAi' +const OpenAiFertilizerRecommendationsSchema = z.object({ + generalRecommendations: z.string().optional(), + recommendedUserFertilizerIds: z.array(z.coerce.string()).optional(), + recommendedProducts: z.array(z.string()).optional(), +}) + +export type IFertilizerRecommendations = ISpeciesFertilizerRecommendations & { recommendedUserFertilizers: IFertilizer[] } + export const getFertilizerRecommendations = async ({ speciesId, userId, }: { speciesId: string, userId?: string, -}): Promise => { +}): Promise => { // Get species const species = await Species.findById(speciesId) @@ -99,7 +109,7 @@ export const getFertilizerRecommendations = async ({ promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") - if (!speciesFertilizerRecommendations?.recommendedProducts.length) { + if (!speciesFertilizerRecommendations?.recommendedProducts?.length) { promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") } @@ -107,7 +117,7 @@ export const getFertilizerRecommendations = async ({ responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] } else { - if (!speciesFertilizerRecommendations?.recommendedProducts.length) { + if (!speciesFertilizerRecommendations?.recommendedProducts?.length) { promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") } } @@ -133,12 +143,9 @@ ${promptSuffix} ` // Call OpenAI API - let text: string - let recommendedUserFertilizerIds: string[] = [] - let recommendedProducts: string[] = [] - + let aiResponse: OpenAI.Chat.Completions.ChatCompletion try { - const completion = await openai.chat.completions.create({ + aiResponse = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { @@ -153,62 +160,64 @@ ${promptSuffix} temperature: 0.7, response_format: { type: 'json_object' }, }) - - const responseContent = completion.choices[0]?.message?.content || '{}' - - try { - const parsedResponse = JSON.parse(responseContent) - - text = parsedResponse.generalRecommendations || 'No general recommendations available' - recommendedUserFertilizerIds = Array.isArray(parsedResponse.recommendedUserFertilizerIds) - ? parsedResponse.recommendedUserFertilizerIds - : [] - recommendedProducts = Array.isArray(parsedResponse.recommendedProducts) - ? parsedResponse.recommendedProducts - : [] - } catch (parseError) { - // If JSON parsing fails, use the raw response as suggestions - text = responseContent - recommendedUserFertilizerIds = [] - recommendedProducts = [] - } } catch (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: `Failed to get fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: `Failed to generate fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, }) } - const newSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations | null = (text && recommendedProducts) ? { - text, - recommendedProducts, - } : null + // Parse AI response + const fertilizerRecommendations: IFertilizerRecommendations = { + text: '', + recommendedUserFertilizers: [], + recommendedProducts: [], + } + try { + const responseContent = aiResponse.choices[0]?.message?.content || '{}' + + const parsedJson: unknown = JSON.parse(responseContent) + const parsedResponse = OpenAiFertilizerRecommendationsSchema.parse(parsedJson) + + fertilizerRecommendations.text = parsedResponse.generalRecommendations || '' + fertilizerRecommendations.recommendedProducts = parsedResponse.recommendedProducts || [] + fertilizerRecommendations.recommendedUserFertilizers = (parsedResponse.recommendedUserFertilizerIds || []) + .map(id => userFertilizers.find(f => f._id.toString() === id) as IFertilizer) + .filter(Boolean) + } catch (parseError) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to parse fertilizer recommendations: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`, + }) + } // Store species-level suggestions (text and recommendedProducts) on Species if they do not already exist - if (!species.fertilizerRecommendations) { + const newSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations = { ...species.fertilizerRecommendations } + if (fertilizerRecommendations.text && !species.fertilizerRecommendations?.text) { + newSpeciesFertilizerRecommendations.text = fertilizerRecommendations.text + } + if (fertilizerRecommendations.recommendedProducts && !species.fertilizerRecommendations?.recommendedProducts) { + newSpeciesFertilizerRecommendations.recommendedProducts = fertilizerRecommendations.recommendedProducts + } + if (newSpeciesFertilizerRecommendations.text !== species.fertilizerRecommendations?.text || newSpeciesFertilizerRecommendations.recommendedProducts !== species.fertilizerRecommendations?.recommendedProducts) { species.fertilizerRecommendations = newSpeciesFertilizerRecommendations await species.save() } - // Gather recommended user fertilizers - const recommendedUserFertilizers: IFertilizer[] = recommendedUserFertilizerIds - .map(id => userFertilizers.find(f => f._id.toString() === id) as IFertilizer) - .filter(Boolean) - // Store user-specific suggestions (recommendedUserFertilizers) on SpeciesUser if they do not already exist - const newSpeciesUser = await SpeciesUser.create({ - species: speciesId, - user: userId, - recommendedFertilizers: recommendedUserFertilizers, - }) - const newSpeciesUserPopulated = await SpeciesUser.findById(newSpeciesUser._id) - .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') + if (fertilizerRecommendations.recommendedUserFertilizers.length > 0) { + await SpeciesUser.create({ + species: speciesId, + user: userId, + recommendedFertilizers: fertilizerRecommendations.recommendedUserFertilizers.map(f => f._id), + }) + } - const response: ISpeciesFertilizerRecommendations & { recommendedUserFertilizers: IFertilizer[] } = { - text: (newSpeciesFertilizerRecommendations || speciesFertilizerRecommendations)?.text || '', - recommendedProducts: (newSpeciesFertilizerRecommendations || speciesFertilizerRecommendations)?.recommendedProducts || [], - recommendedUserFertilizers: (newSpeciesUserPopulated?.toObject() || speciesUser?.toObject())?.recommendedFertilizers || [], + const response: IFertilizerRecommendations = { + text: fertilizerRecommendations.text || speciesFertilizerRecommendations?.text || '', + recommendedProducts: fertilizerRecommendations.recommendedProducts || speciesFertilizerRecommendations?.recommendedProducts || [], + recommendedUserFertilizers: fertilizerRecommendations.recommendedUserFertilizers || speciesUser?.toObject().recommendedFertilizers || [], } return response From d68090fcecc75ffc766ab8434b6f102a68f9fbc0 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 28 Jan 2026 09:07:16 -1000 Subject: [PATCH 7/8] Only early return existing if all fields are populated --- apps/api/src/services/species/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index 73c5bfc..8a14a05 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -47,7 +47,7 @@ export const getFertilizerRecommendations = async ({ .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') // If we already have both species-level suggestions and user-level fertilizer recommendations (if userId was passed), return the existing data - if (speciesFertilizerRecommendations && (!userId || speciesUser?.recommendedFertilizers)) { + if (speciesFertilizerRecommendations?.text && speciesFertilizerRecommendations?.recommendedProducts && (!userId || speciesUser?.recommendedFertilizers)) { const existingData = { ...speciesFertilizerRecommendations, recommendedUserFertilizers: speciesUser?.recommendedFertilizers || [], From 91c0baa196a136dabc1a37852aa09fffda49e06b Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 28 Jan 2026 09:15:05 -1000 Subject: [PATCH 8/8] clean up --- apps/api/src/services/species/index.ts | 176 ++++++++++++++----------- 1 file changed, 98 insertions(+), 78 deletions(-) diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index 8a14a05..f630903 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -7,6 +7,7 @@ import { Species, SpeciesUser, type IFertilizer, + type ISpecies, type ISpeciesFertilizerRecommendations, } from '../../models' @@ -50,7 +51,7 @@ export const getFertilizerRecommendations = async ({ if (speciesFertilizerRecommendations?.text && speciesFertilizerRecommendations?.recommendedProducts && (!userId || speciesUser?.recommendedFertilizers)) { const existingData = { ...speciesFertilizerRecommendations, - recommendedUserFertilizers: speciesUser?.recommendedFertilizers || [], + recommendedUserFertilizers: speciesUser?.toObject().recommendedFertilizers || [], } return existingData @@ -64,83 +65,11 @@ export const getFertilizerRecommendations = async ({ const openai = openAiService.createClient() - // Build species information for prompt - const speciesInfo = [ - species.commonName && `Common name: ${species.commonName}`, - species.scientificName && `Scientific name: ${species.scientificName}`, - species.genus && `Genus: ${species.genus}`, - species.family && `Family: ${species.family}`, - species.familyCommonName && `Family common name: ${species.familyCommonName}`, - ].filter(Boolean).join('\n') - - // Gather prompt instructions - let promptPrefix = '' - let promptInstructionsSpec = [] - let promptSuffix = `If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` - - const responseFormatSpec: { - generalRecommendations: string, - recommendedUserFertilizerIds: string[], - recommendedProducts: string[], - } = { - generalRecommendations: 'Your detailed text response here', - recommendedUserFertilizerIds: [], - recommendedProducts: ['Brand Name - Product Name (N-P-K)', 'Brand Name - Product Name (N-P-K)'], - } - - if (!speciesFertilizerRecommendations?.text) { - promptInstructionsSpec.push('A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant.') - } - - if (userFertilizers && userFertilizers.length > 0) { - const userFertilizersList = userFertilizers - .map(fertilizer => { - const npk = [ - fertilizer.nitrogen !== null ? `N:${fertilizer.nitrogen}` : null, - fertilizer.phosphorus !== null ? `P:${fertilizer.phosphorus}` : null, - fertilizer.potassium !== null ? `K:${fertilizer.potassium}` : null, - ].filter(Boolean).join('-') || 'NPK not specified' - - return `- (id: ${fertilizer._id}) ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk})` - }) - .join('\n') - - promptPrefix = `The user currently owns the following fertilizers:\n${userFertilizersList}` - - promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") - - if (!speciesFertilizerRecommendations?.recommendedProducts?.length) { - promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") - } - - promptSuffix = `If no fertilizers from the inventory are suitable, set "recommendedUserFertilizerIds" to an empty array.\n\n${promptSuffix}` - - responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] - } else { - if (!speciesFertilizerRecommendations?.recommendedProducts?.length) { - promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") - } - } - - const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: -- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) -- Application frequency and timing -- Specific nutrient requirements -- Any special considerations for this species - -Plant information: -${speciesInfo} - -${promptPrefix} - -Please provide: -${promptInstructionsSpec.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n')} - -Format your response as JSON with the following structure: -${JSON.stringify(responseFormatSpec)} - -${promptSuffix} -` + const prompt = buildGetFertilizerRecommendationsPrompt({ + species, + existingSpeciesFertilizerRecommendations: speciesFertilizerRecommendations, + userFertilizers, + }) // Call OpenAI API let aiResponse: OpenAI.Chat.Completions.ChatCompletion @@ -222,3 +151,94 @@ ${promptSuffix} return response } + +const buildGetFertilizerRecommendationsPrompt = ({ + species, + existingSpeciesFertilizerRecommendations, + userFertilizers, +}: { + species: ISpecies, + existingSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations | null, + userFertilizers: IFertilizer[], +}) => { + // Gather prompt instructions + let promptPrefix = '' + let promptInstructionsSpec = [] + let promptSuffix = `If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` + const responseFormatSpec: { + generalRecommendations: string, + recommendedUserFertilizerIds: string[], + recommendedProducts: string[], + } = { + generalRecommendations: 'Your detailed text response here', + recommendedUserFertilizerIds: [], + recommendedProducts: ['Brand Name - Product Name (N-P-K)', 'Brand Name - Product Name (N-P-K)'], + } + + // Build species information for prompt + const speciesInfo = [ + species.commonName && `Common name: ${species.commonName}`, + species.scientificName && `Scientific name: ${species.scientificName}`, + species.genus && `Genus: ${species.genus}`, + species.family && `Family: ${species.family}`, + species.familyCommonName && `Family common name: ${species.familyCommonName}`, + ].filter(Boolean).join('\n') + + // Generate prompt + if (!existingSpeciesFertilizerRecommendations?.text) { + promptInstructionsSpec.push('A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant.') + } + + // Tailor prompt based on whether or not the user has fertilizers + if (userFertilizers && userFertilizers.length > 0) { + const userFertilizersList = userFertilizers + .map(fertilizer => { + const npk = [ + fertilizer.nitrogen !== null ? `N:${fertilizer.nitrogen}` : null, + fertilizer.phosphorus !== null ? `P:${fertilizer.phosphorus}` : null, + fertilizer.potassium !== null ? `K:${fertilizer.potassium}` : null, + ].filter(Boolean).join('-') || 'NPK not specified' + + return `- (id: ${fertilizer._id}) ${fertilizer.name} (${fertilizer.type}, ${fertilizer.isOrganic ? 'organic' : 'synthetic'}, ${npk})` + }) + .join('\n') + + promptPrefix = `The user currently owns the following fertilizers:\n${userFertilizersList}` + + promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") + + if (!existingSpeciesFertilizerRecommendations?.recommendedProducts?.length) { + promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") + } + + promptSuffix = `If no fertilizers from the inventory are suitable, set "recommendedUserFertilizerIds" to an empty array.\n\n${promptSuffix}` + + responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] + } else { + if (!existingSpeciesFertilizerRecommendations?.recommendedProducts?.length) { + promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") + } + } + + const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: +- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) +- Application frequency and timing +- Specific nutrient requirements +- Any special considerations for this species + +Plant information: +${speciesInfo} + +${promptPrefix} + +Please provide: +${promptInstructionsSpec.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n')} + +Format your response as JSON with the following structure: +${JSON.stringify(responseFormatSpec)} + +${promptSuffix} +` + + return prompt +}