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..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": { @@ -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/getFertilizerRecommendations.ts b/apps/api/src/endpoints/trpc/species/getFertilizerRecommendations.ts new file mode 100644 index 0000000..92d643b --- /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: fertilizerRecommendations.recommendedUserFertilizers, + recommendedProducts: fertilizerRecommendations.recommendedProducts, + } + }) diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index e39c7ea..5b357ef 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 ISpeciesFertilizerRecommendations { + 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, + fertilizerRecommendations: ISpeciesFertilizerRecommendations | null, createdAt: Date, updatedAt: Date, } @@ -54,6 +60,13 @@ export const speciesSchema = new mongoose.Schema({ type: String, default: null, }, + fertilizerRecommendations: { + 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/routers/trpc/species.ts b/apps/api/src/routers/trpc/species.ts index 287d806..0fc751e 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 { getFertilizerRecommendations } from '../../endpoints/trpc/species/getFertilizerRecommendations' import { listSpecies } from '../../endpoints/trpc/species/listSpecies' export const speciesRouter = router({ + getFertilizerRecommendations, list: listSpecies, }) 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..f630903 --- /dev/null +++ b/apps/api/src/services/species/index.ts @@ -0,0 +1,244 @@ +import { TRPCError } from '@trpc/server' +import type OpenAI from 'openai' +import { z } from 'zod' + +import { + Fertilizer, + Species, + SpeciesUser, + type IFertilizer, + type ISpecies, + type ISpeciesFertilizerRecommendations, +} from '../../models' + +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 => { + // Get species + const species = await Species.findById(speciesId) + + if (!species) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Species not found', + }) + } + + const speciesFertilizerRecommendations = species.toObject().fertilizerRecommendations + + // 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 (speciesFertilizerRecommendations?.text && speciesFertilizerRecommendations?.recommendedProducts && (!userId || speciesUser?.recommendedFertilizers)) { + const existingData = { + ...speciesFertilizerRecommendations, + recommendedUserFertilizers: speciesUser?.toObject().recommendedFertilizers || [], + } + + return existingData + } + + // Fetch user's fertilizers + const userFertilizers = !userId ? [] : await Fertilizer.find({ + user: userId, + deletedAt: null, + }).sort({ name: 'asc' }) + + const openai = openAiService.createClient() + + const prompt = buildGetFertilizerRecommendationsPrompt({ + species, + existingSpeciesFertilizerRecommendations: speciesFertilizerRecommendations, + userFertilizers, + }) + + // Call OpenAI API + let aiResponse: OpenAI.Chat.Completions.ChatCompletion + try { + aiResponse = 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' }, + }) + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to generate fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + } + + // 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 + 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() + } + + // Store user-specific suggestions (recommendedUserFertilizers) on SpeciesUser if they do not already exist + if (fertilizerRecommendations.recommendedUserFertilizers.length > 0) { + await SpeciesUser.create({ + species: speciesId, + user: userId, + recommendedFertilizers: fertilizerRecommendations.recommendedUserFertilizers.map(f => f._id), + }) + } + + const response: IFertilizerRecommendations = { + text: fertilizerRecommendations.text || speciesFertilizerRecommendations?.text || '', + recommendedProducts: fertilizerRecommendations.recommendedProducts || speciesFertilizerRecommendations?.recommendedProducts || [], + recommendedUserFertilizers: fertilizerRecommendations.recommendedUserFertilizers || speciesUser?.toObject().recommendedFertilizers || [], + } + + 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 +} 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 06157eb..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 - 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 - - // 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 + // Backwards-compat deep link support: /(tabs)/plants?expandPlantId= 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) + if (!expandPlantId) return + if (isLoading) return + if (!data?.plants?.some(p => p._id === expandPlantId)) return - 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,473 +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 } - ) - - // 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 - - - )) || ( - <> - 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..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, @@ -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 }} > @@ -518,7 +518,6 @@ export default function ChoreDetailScreen() { 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/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/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/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/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/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} > + + + + 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) => { + 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} + )} + + ) + })} + + )} + + {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..c27f3cd --- /dev/null +++ b/apps/mobile/src/components/PlantHistogram.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { 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' + +export function PlantHistogram({ + plant, +}: { + plant: Endpoints['plants']['list']['plants'][number], +}) { + const [timeRange, setTimeRange] = React.useState<'Week' | 'Month' | 'Year'>('Year') + + // 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, }, 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",