diff --git a/README.md b/README.md index 7038034..8393ff7 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,33 @@ These commands boot a test API at `http://localhost:3050` using an in-memory Mon - The shared package ensures type consistency between all applications - The API server handles all database connections and business logic +## Database backups and restores + +### Creating a backup + +- **Run backup script**: + +```bash +npm run dbBackup +``` + +This will: + +- Connect to the MongoDB instance configured via `apps/api/.env` +- Save and zip the result of `mongodump` to `mongodb_backup_YYYY-MM-DD_HH-MM-SS.zip` + +### Restoring from a backup + +- **Run restore script**: + +```bash +# From repo root: +npm run dbRestore backups/mongodb_backup_YYYY-MM-DD_HH-MM-SS.zip + +# Or, if you have an already-extracted folder: +npm run dbRestore backups/mongodb_backup_YYYY-MM-DD_HH-MM-SS +``` + ## Verbose debug logging Verbose debug logging is facilitated by the [debug](https://www.npmjs.com/package/debug) package, and is turned off by default. diff --git a/apps/api/.env.example b/apps/api/.env.example index e9b389e..90abd61 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,7 +8,7 @@ API_BASE_URL=http://localhost:3000 MOBILE_APP_BASE_URL=plannting:// # Enforce a minimum required mobile app version -MOBILE_APP_MINIMUM_REQUIRED_VERSION=0.13.0 +MOBILE_APP_MINIMUM_REQUIRED_VERSION=0.14.0 # Debug logging # See readme for "Verbose debug logging" diff --git a/apps/api/backups/mongodb_backup_2026-03-11_22-04-52.zip b/apps/api/backups/mongodb_backup_2026-03-11_22-04-52.zip new file mode 100644 index 0000000..5d74cd7 Binary files /dev/null and b/apps/api/backups/mongodb_backup_2026-03-11_22-04-52.zip differ diff --git a/apps/api/backups/mongodb_backup_2026-03-16_15-20-39.zip b/apps/api/backups/mongodb_backup_2026-03-16_15-20-39.zip new file mode 100644 index 0000000..5a5bcdd Binary files /dev/null and b/apps/api/backups/mongodb_backup_2026-03-16_15-20-39.zip differ diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js index 0941a32..1fa323b 100644 --- a/apps/api/jest.config.js +++ b/apps/api/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + setupFiles: ['/jest.setup.js'], roots: ['/src'], testMatch: [ '**/__tests__/**/*.test.ts', diff --git a/apps/api/jest.setup.js b/apps/api/jest.setup.js new file mode 100644 index 0000000..1ddd691 --- /dev/null +++ b/apps/api/jest.setup.js @@ -0,0 +1,6 @@ +// Ensure a valid MongoDB URI is set when config/db load, so MongoClient doesn't throw +// "Invalid scheme, expected connection string to start with mongodb:// or mongodb+srv://". +// Tests that need the DB mock rawClient; no real connection is made. +if (!process.env.MONGO_URI) { + process.env.MONGO_URI = 'mongodb://localhost:27017/jest' +} diff --git a/apps/api/package.json b/apps/api/package.json index decf348..7789e8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,9 @@ "start": "node dist/index.js", "lint": "tsc --noEmit", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "dbBackup": "tsx src/scripts/dbBackup.ts", + "dbRestore": "tsx src/scripts/dbRestore.ts" }, "dependencies": { "@trpc/server": "^11.5.1", diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 3bb2444..6f7b1cd 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -16,7 +16,7 @@ export const config: ApiConfig = { baseUrl: process.env.MOBILE_APP_BASE_URL, // Used by the mobile app to determine whether it must hard-block the UI until the user updates. // Default is 0.0.0 so we never block unless explicitly configured. - minimumRequiredVersion: (process.env.MOBILE_APP_MINIMUM_REQUIRED_VERSION ?? '0.13.0') as SemVerString, + minimumRequiredVersion: (process.env.MOBILE_APP_MINIMUM_REQUIRED_VERSION ?? '0.14.0') as SemVerString, storeUrls: { android: process.env.MOBILE_APP_STORE_URL_ANDROID ?? 'https://play.google.com/store/apps/details?id=com.completecodesolutions.***', ios: process.env.MOBILE_APP_STORE_URL_IOS ?? 'https://apps.apple.com/us/app/***/***', @@ -25,10 +25,12 @@ export const config: ApiConfig = { mongo: { host: process.env.MONGO_HOST, appName: process.env.MONGO_APP_NAME, + dbName: process.env.MONGO_DB_NAME, password: process.env.MONGO_PASSWORD, user: process.env.MONGO_USER, uri: process.env.MONGO_URI // For automated tests, we use a in-memory MongoDB - || `mongodb+srv://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@${process.env.MONGO_HOST}/${process.env.MONGO_DB_NAME}?retryWrites=true&w=majority&appName=${process.env.MONGO_APP_NAME}`, + || (process.env.MONGO_HOST && `mongodb+srv://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@${process.env.MONGO_HOST}/${process.env.MONGO_DB_NAME}?retryWrites=true&w=majority&appName=${process.env.MONGO_APP_NAME}`) + || '', }, openai: { apiKey: process.env.OPENAI_API_KEY, diff --git a/apps/api/src/config/types.ts b/apps/api/src/config/types.ts index e33a9bc..6af728d 100644 --- a/apps/api/src/config/types.ts +++ b/apps/api/src/config/types.ts @@ -25,6 +25,7 @@ export type ApiConfig = { mongo: { host: string | undefined, appName: string | undefined, + dbName: string | undefined, password: string | undefined, user: string | undefined, uri: string, diff --git a/apps/api/src/endpoints/trpc/plants/chat.ts b/apps/api/src/endpoints/trpc/plants/chat.ts index 7c719b4..0c40950 100644 --- a/apps/api/src/endpoints/trpc/plants/chat.ts +++ b/apps/api/src/endpoints/trpc/plants/chat.ts @@ -62,7 +62,8 @@ export const chat = authProcedure const species = plant.species if (species) { - systemParts.push('', '**Species**', `- Common name: ${species.commonName}`) + const primaryCommonName = species.commonNames?.[0]?.name + systemParts.push('', '**Species**', `- Common name: ${primaryCommonName ?? '(none)'}`) if (species.scientificName) systemParts.push(`- Scientific name: ${species.scientificName}`) if (species.familyCommonName) systemParts.push(`- Family: ${species.familyCommonName}`) if (species.genus) systemParts.push(`- Genus: ${species.genus}`) diff --git a/apps/api/src/endpoints/trpc/species/listSpecies.ts b/apps/api/src/endpoints/trpc/species/listSpecies.ts index ca218dc..45213e3 100644 --- a/apps/api/src/endpoints/trpc/species/listSpecies.ts +++ b/apps/api/src/endpoints/trpc/species/listSpecies.ts @@ -1,7 +1,5 @@ import { z } from 'zod' -import { Species } from '../../../models' - import { authProcedure } from '../../../procedures/authProcedure' import * as speciesLookupService from '../../../services/speciesLookup' @@ -14,35 +12,7 @@ export const listSpecies = authProcedure .default({}) ) .query(async ({ input }) => { - const speciesFromService = await speciesLookupService.findSpecies({ q: input.q }) - - // Find or create species in database - const speciesPromises = speciesFromService.map(async (speciesData) => { - // Try to find existing species by source and sourceId - let species = await Species.findOne({ - source: speciesData.source, - sourceId: speciesData.sourceId, - }) - - // If not found, create it - if (!species) { - species = await Species.create({ - source: speciesData.source, - sourceId: speciesData.sourceId, - imageUrl: speciesData.imageUrl, - commonName: speciesData.commonName, - scientificName: speciesData.scientificName, - familyCommonName: speciesData.familyCommonName, - synonyms: speciesData.synonyms, - genus: speciesData.genus, - family: speciesData.family, - }) - } - - return species.toJSON() - }) - - const species = await Promise.all(speciesPromises) + const species = await speciesLookupService.findAndCacheSpecies({ q: input.q }) return { species } }) diff --git a/apps/api/src/models/Plant.ts b/apps/api/src/models/Plant.ts index 5f93b6c..aa21ca2 100644 --- a/apps/api/src/models/Plant.ts +++ b/apps/api/src/models/Plant.ts @@ -12,7 +12,7 @@ export interface IPlant { chores: mongoose.Types.ObjectId[], plantedAt: Date | null, species: mongoose.Types.ObjectId | null, - /** User-uploaded photo URL (e.g. Vercel Blob). For species image use populated plant.species.imageUrl. */ + /** User-uploaded photo URL (e.g. Vercel Blob). For species image use populated plant.species.images.*.url. */ photoUrl: string | null, createdAt: Date, updatedAt: Date, diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index d9d1532..8511716 100644 --- a/apps/api/src/models/Species.ts +++ b/apps/api/src/models/Species.ts @@ -2,58 +2,76 @@ import mongoose from 'mongoose' export interface ISpecies { _id: string, - source: 'trefle', - sourceId: number, - imageUrl: string | null, - commonName: string, - scientificName: string | null, - familyCommonName: string | null, - synonyms: string[] | null, + sourceId_plantNet: string | null, + sourceId_trefle: number | null, + commonNames: SpeciesCommonName[], + images: SpeciesImage[], genus: string | null, family: string | null, + familyCommonName: string | null, + scientificName: string, + altScientificNames: string[], // Alternative scientific names (aka synonyms). These are often older outdated names. fertilizerSuggestions: string | null, createdAt: Date, updatedAt: Date, } +export type SpeciesImage = { + source: SpeciesDatumSource, + url: string, +} + +export type SpeciesCommonName = { + name: string, + sources: SpeciesDatumSource[], +} + +export type SpeciesDatumSource = 'trefle' | 'plantNet' + export const speciesSchema = new mongoose.Schema({ - source: { + sourceId_plantNet: { type: String, - enum: ['trefle'], - required: true, + default: null, + trim: true, }, - sourceId: { + sourceId_trefle: { type: Number, - required: true, - }, - imageUrl: { - type: String, default: null, }, - commonName: { - type: String, - required: true, - trim: true, + commonNames: { + type: [{ + name: { type: String, required: true, trim: true }, + sources: { type: [String], enum: ['trefle', 'plantNet'], default: [] }, + }], + default: [], }, - scientificName: { - type: String, - default: null, + images: { + type: [{ + url: { type: String, required: true, trim: true }, + source: { type: String, enum: ['trefle', 'plantNet'], required: true }, + }], + default: [], }, - familyCommonName: { + genus: { type: String, default: null, }, - synonyms: { - type: [String], + family: { + type: String, default: null, }, - genus: { + familyCommonName: { type: String, default: null, }, - family: { + scientificName: { type: String, - default: null, + required: true, + trim: true, + }, + altScientificNames: { + type: [String], + default: [], }, fertilizerSuggestions: { type: String, @@ -64,7 +82,33 @@ export const speciesSchema = new mongoose.Schema({ timestamps: true, }) -// Create unique index on source + sourceId to prevent duplicates -speciesSchema.index({ source: 1, sourceId: 1 }, { unique: true }) +// Unique scientific name (case-insensitive due to schema collation) +speciesSchema.index({ scientificName: 1 }, { unique: true }) + +speciesSchema.pre('validate', function normalizeSpecies(next) { + if (typeof this.scientificName === 'string') { + this.scientificName = normalizeScientificName(this.scientificName) + } + + this.altScientificNames = Array.from(new Set((this.altScientificNames ?? []).map(normalizeScientificName).filter(Boolean))) + + this.images = (this.images ?? []) + .map(img => ({ url: img.url?.trim?.() ?? img.url, source: img.source })) + .filter(img => !!img.url) + + this.commonNames = (this.commonNames ?? []) + .map(cn => ({ name: cn.name?.trim?.() ?? cn.name, sources: Array.from(new Set(cn.sources ?? [])) })) + .filter(cn => !!cn.name) + + next() +}) + +// For scientific names always have the first letter capitalized and all other letters lowercase +const normalizeScientificName = (name: string) => { + const trimmed = name.trim().replace(/\s+/g, ' ') // Trim and collapse any whitespace runs (multiple spaces/tabs/newlines) into a single space + if (!trimmed) return trimmed + + return trimmed[0]!.toUpperCase() + trimmed.slice(1).toLowerCase() +} export const Species = mongoose.model('Species', speciesSchema) diff --git a/apps/api/src/scripts/dbBackup.ts b/apps/api/src/scripts/dbBackup.ts new file mode 100644 index 0000000..d2d5973 --- /dev/null +++ b/apps/api/src/scripts/dbBackup.ts @@ -0,0 +1,83 @@ +import 'dotenv/config' +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { config } from '../config' + +function runCommand( + command: string, + args: string[], + options?: { cwd?: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: 'inherit', + env: process.env, + }) + + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) return resolve() + reject(new Error(`${command} exited with code ${code ?? 'unknown'}`)) + }) + }) +} + +function timestampStampString(now = new Date()) { + const pad2 = (n: number) => String(n).padStart(2, '0') + const yyyy = now.getFullYear() + const mm = pad2(now.getMonth() + 1) + const dd = pad2(now.getDate()) + const hh = pad2(now.getHours()) + const min = pad2(now.getMinutes()) + const ss = pad2(now.getSeconds()) + + return `${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}` +} + +async function main() { + if (!config.mongo.uri) { + throw new Error('Missing MONGO_URI / config.mongo.uri') + } + + if (!config.mongo.dbName) { + throw new Error('Missing MONGO_DB_NAME / config.mongo.dbName') + } + + const repoRoot = process.cwd() + const backupsDir = path.join(repoRoot, 'backups') + await fs.mkdir(backupsDir, { recursive: true }) + + const stamp = timestampStampString() + const dumpName = `mongodb_backup_${stamp}` + const dumpPath = path.join(backupsDir, dumpName) + + // Stream output so the user sees mongodump/zip progress in the CLI. + await runCommand('mongodump', [`--uri=${config.mongo.uri}`, `--out=${dumpPath}`]) + + const dbSubDir = path.join(dumpPath, config.mongo.dbName) + // Remove the subdirectory named after the db. This is important so that future restores can restore a db with a different name. + await runCommand('sh', [ + '-c', + 'mv "$1"/* "$2"/ && rmdir "$1"', + '_', + dbSubDir, + dumpPath, + ]) + + console.log(`Backup files created in ${dumpPath}, zipping...`) + await runCommand('zip', ['-r', `${dumpName}.zip`, dumpName], { cwd: backupsDir }) + + // Remove the original dump directory now that the zip exists + await fs.rm(dumpPath, { recursive: true, force: true }) + + console.log(`Backup zip saved to ${dumpName}.zip`) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err) + process.exit(1) +}) diff --git a/apps/api/src/scripts/dbRestore.ts b/apps/api/src/scripts/dbRestore.ts new file mode 100644 index 0000000..8ec88b2 --- /dev/null +++ b/apps/api/src/scripts/dbRestore.ts @@ -0,0 +1,102 @@ +import 'dotenv/config' + +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { config } from '../config' + +function runCommand( + command: string, + args: string[], + options?: { cwd?: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: 'inherit', + env: process.env, + }) + + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) return resolve() + reject(new Error(`${command} exited with code ${code ?? 'unknown'}`)) + }) + }) +} + +async function pathExists(p: string) { + try { + await fs.access(p) + return true + } catch { + return false + } +} + +async function pickRestoreDirFromExtract(extractDir: string) { + const entries = await fs.readdir(extractDir, { withFileTypes: true }) + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name) + + if (dirs.length === 1) { + return path.join(extractDir, dirs[0]!) + } + + // If the zip had multiple items, assume the root extract dir is the mongorestore target. + return extractDir +} + +async function resolveBackupArg(rawArg: string) { + const repoRoot = process.cwd() + const backupsDir = path.join(repoRoot, 'backups') + + const candidatePaths = [ + rawArg, + path.isAbsolute(rawArg) ? null : path.join(repoRoot, rawArg), + path.isAbsolute(rawArg) ? null : path.join(backupsDir, rawArg), + ].filter(Boolean) as string[] + + for (const p of candidatePaths) { + if (await pathExists(p)) return p + } + + throw new Error( + `Backup not found: "${rawArg}". Looked in: ${candidatePaths.join(', ')}`, + ) +} + +async function main() { + if (!config.mongo.uri) { + throw new Error('Missing MONGO_URI / config.mongo.uri') + } + + const rawBackup = process.argv[2] + if (!rawBackup) { + throw new Error( + 'Missing backup path. Usage: npm -w apps/api run dbRestore -- ', + ) + } + + const backupPath = await resolveBackupArg(rawBackup) + + let restoreDir = backupPath + if (backupPath.toLowerCase().endsWith('.zip')) { + const repoRoot = process.cwd() + const tmpRoot = path.join(repoRoot, 'backups', '.tmp-restore-') + await fs.mkdir(path.dirname(tmpRoot), { recursive: true }) + const extractDir = await fs.mkdtemp(tmpRoot) + + await runCommand('unzip', ['-o', backupPath, '-d', extractDir]) + restoreDir = await pickRestoreDirFromExtract(extractDir) + } + + await runCommand('mongorestore', ['--uri', config.mongo.uri, '--drop', restoreDir]) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err) + process.exit(1) +}) + diff --git a/apps/api/src/scripts/migrateSpeciesToMultiSource.ts b/apps/api/src/scripts/migrateSpeciesToMultiSource.ts new file mode 100644 index 0000000..59da802 --- /dev/null +++ b/apps/api/src/scripts/migrateSpeciesToMultiSource.ts @@ -0,0 +1,386 @@ +/** + * + * + * + * This is intended as a ONE-TIME USE migration script to migrate the Species collection from the old single-source model to the new multi-source model. + * + * + * + */ + +import path from 'node:path' + +import dotenv from 'dotenv' + +import { ObjectId } from 'mongodb' + +// IMPORTANT: we must load env BEFORE importing ../config or ../db, +// because ../config constructs config.mongo.uri at import-time. +function loadEnv() { + // Load both root `.env` and `apps/api/.env` explicitly (later wins). + dotenv.config({ path: path.resolve(process.cwd(), '.env') }) + dotenv.config({ path: path.resolve(process.cwd(), 'apps/api/.env') }) +} + +type SpeciesSource = 'trefle' | 'plantNet' + +type SpeciesImage = { url: string, source: SpeciesSource } +type SpeciesCommonName = { name: string, sources: SpeciesSource[] } + +const normalizeScientificName = (name: string) => { + const trimmed = name.trim().replace(/\s+/g, ' ') + if (!trimmed) return trimmed + return trimmed[0]!.toUpperCase() + trimmed.slice(1).toLowerCase() +} + +const normalizeLooseKey = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase() + +function uniq(items: T[]) { + return Array.from(new Set(items)) +} + +function mergeImages(existing: SpeciesImage[], incoming: SpeciesImage[]) { + const key = (img: SpeciesImage) => `${img.source}:${normalizeLooseKey(img.url)}` + const map = new Map(existing.map(img => [key(img), img])) + for (const img of incoming) { + const k = key(img) + if (!map.has(k)) map.set(k, img) + } + return Array.from(map.values()) +} + +function mergeCommonNames(existing: SpeciesCommonName[], incoming: SpeciesCommonName[]) { + const key = (name: string) => normalizeLooseKey(name) + const map = new Map(existing.map(cn => [key(cn.name), cn])) + for (const cn of incoming) { + const k = key(cn.name) + const cur = map.get(k) + if (!cur) { + map.set(k, { name: cn.name, sources: uniq(cn.sources) }) + } else { + cur.sources = uniq([...(cur.sources ?? []), ...(cn.sources ?? [])]) + } + } + return Array.from(map.values()) +} + +const OLD_SPECIES_INDEX = { source: 1, sourceId: 1 } + +async function migrateUp(dryRun: boolean) { + const { Species } = await import('../models') + const collection = Species.collection + + // Drop the old unique index so $unset of source/sourceId doesn't cause duplicate key errors + // (multiple docs with (null, null) would violate a non-sparse unique index). + if (!dryRun) { + const indexes = await collection.indexes() + const oldIndex = indexes.find( + (idx) => + idx.key && 'source' in idx.key && 'sourceId' in idx.key && idx.key.source === 1 && idx.key.sourceId === 1 + ) + if (oldIndex?.name) { + await collection.dropIndex(oldIndex.name) + // eslint-disable-next-line no-console + console.log(`Dropped old Species index: ${oldIndex.name}`) + } + } + + const rawDocs = await collection.find({}).toArray() + + const byScientificName = new Map() + const noScientificName: any[] = [] + + for (const doc of rawDocs) { + const sci = typeof doc.scientificName === 'string' ? doc.scientificName : '' + const normalized = normalizeScientificName(sci) + if (!normalized) { + noScientificName.push(doc) + continue + } + const arr = byScientificName.get(normalized) ?? [] + arr.push(doc) + byScientificName.set(normalized, arr) + } + + let updated = 0 + let mergedGroups = 0 + let deleted = 0 + + for (const [scientificName, docs] of Array.from(byScientificName.entries())) { + // Keep oldest as primary (stable), merge the rest into it. + docs.sort((a, b) => { + const at = new Date(a.createdAt ?? 0).getTime() + const bt = new Date(b.createdAt ?? 0).getTime() + return at - bt + }) + + const primary = docs[0]! + const others = docs.slice(1) + + const merged = docs.reduce((acc, doc) => { + const source: SpeciesSource = + doc.source === 'plantNet' ? 'plantNet' + : doc.source === 'trefle' ? 'trefle' + : 'trefle' + + const sourceId_trefle = + (typeof doc.sourceId_trefle === 'number' ? doc.sourceId_trefle : null) ?? + (typeof doc.trefleId === 'number' ? doc.trefleId : null) ?? + (typeof doc.sourceId === 'number' ? doc.sourceId : null) + + const sourceId_plantNet = + (typeof doc.sourceId_plantNet === 'string' ? doc.sourceId_plantNet : null) ?? + (typeof doc.plantNetId === 'string' ? doc.plantNetId : null) ?? + null + + const imagesExisting: SpeciesImage[] = Array.isArray(doc.images) ? doc.images : [] + const imageUrl = typeof doc.imageUrl === 'string' ? doc.imageUrl.trim() : '' + const imagesIncoming: SpeciesImage[] = imageUrl ? [{ url: imageUrl, source }] : [] + + const commonNamesExisting: SpeciesCommonName[] = Array.isArray(doc.commonNames) ? doc.commonNames : [] + const commonName = typeof doc.commonName === 'string' ? doc.commonName.trim() : '' + const commonNamesIncoming: SpeciesCommonName[] = commonName ? [{ name: commonName, sources: [source] }] : [] + + const altExisting: string[] = Array.isArray(doc.altScientificNames) ? doc.altScientificNames : [] + const syns: string[] = Array.isArray(doc.synonyms) ? doc.synonyms : [] + const altIncoming = syns.map((s: unknown) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) + + acc.sourceId_trefle = acc.sourceId_trefle ?? sourceId_trefle + acc.sourceId_plantNet = acc.sourceId_plantNet ?? sourceId_plantNet + + acc.images = mergeImages(acc.images, mergeImages(imagesExisting, imagesIncoming)) + acc.commonNames = mergeCommonNames(acc.commonNames, mergeCommonNames(commonNamesExisting, commonNamesIncoming)) + acc.altScientificNames = uniq([...(acc.altScientificNames ?? []), ...altExisting, ...altIncoming].map(s => s.trim()).filter(Boolean)) + + acc.familyCommonName = acc.familyCommonName ?? (typeof doc.familyCommonName === 'string' ? doc.familyCommonName : null) + acc.genus = acc.genus ?? (typeof doc.genus === 'string' ? doc.genus : null) + acc.family = acc.family ?? (typeof doc.family === 'string' ? doc.family : null) + + acc.fertilizerSuggestions = acc.fertilizerSuggestions ?? (typeof doc.fertilizerSuggestions === 'string' ? doc.fertilizerSuggestions : null) + + return acc + }, { + scientificName, + sourceId_trefle: null as number | null, + sourceId_plantNet: null as string | null, + images: [] as SpeciesImage[], + commonNames: [] as SpeciesCommonName[], + altScientificNames: [] as string[], + familyCommonName: null as string | null, + genus: null as string | null, + family: null as string | null, + fertilizerSuggestions: null as string | null, + }) + + const primaryId = primary._id instanceof ObjectId ? primary._id : new ObjectId(primary._id) + + const updateDoc = { + $set: merged, + $unset: { + source: '', + sourceId: '', + imageUrl: '', + commonName: '', + synonyms: '', + trefleId: '', + plantNetId: '', + }, + } + + if (!dryRun) { + await collection.updateOne({ _id: primaryId }, updateDoc) + .then(() => { + console.log(`Updated species record with ID ${primaryId.toString()}`) + }) + .catch((err) => { + console.log('Failed to run the following command:') + console.dir({ update: { where: { _id: primaryId.toString() }, from: primary, to: updateDoc } }, { depth: null }) + + console.log('With the following error:') + console.error(err) + + process.exit(1) + }) + } + else { + console.dir({ update: { where: { _id: primaryId.toString() }, from: primary, to: updateDoc } }, { depth: null }) + } + updated += 1 + + if (others.length > 0) { + mergedGroups += 1 + const otherIds = others.map(d => (d._id instanceof ObjectId ? d._id : new ObjectId(d._id))) + if (!dryRun) { + const res = await collection.deleteMany({ _id: { $in: otherIds } }) + .then((res) => { + console.log(`Deleted species record(s) with ID IN ${otherIds.map(id => id.toString()).join(', ')}`) + + return res + }) + .catch((err) => { + console.log('Failed to run the following command:') + console.dir({ deleteMany: { where: { _id: { $in: otherIds.map(id => id.toString()) } } } }, { depth: null }) + + console.log('With the following error:') + console.error(err) + + process.exit(1) + }) + + deleted += res.deletedCount ?? 0 + } else { + console.dir({ deleteMany: { where: { _id: { $in: otherIds.map(id => id.toString()) } } } }, { depth: null }) + + deleted += otherIds.length + } + } + } + + if (noScientificName.length > 0) { + // These docs cannot satisfy the new schema (scientificName required + unique). + // We keep them untouched; you can inspect/remove them manually if needed. + // eslint-disable-next-line no-console + console.warn(`Found ${noScientificName.length} species without scientificName; left unchanged.`) + } + + if (dryRun) { + // eslint-disable-next-line no-console + console.log('--DRY RUN ONLY, NO DATA ALTERED--') + } + // eslint-disable-next-line no-console + console.log(`UP complete. dryRun=${dryRun}. Updated groups=${updated}. Merged groups=${mergedGroups}. Deleted duplicates=${deleted}.`) +} + +async function migrateDown(dryRun: boolean) { + const { Species } = await import('../models') + const collection = Species.collection + const rawDocs = await collection.find({}).toArray() + + let updated = 0 + + for (const doc of rawDocs) { + const id = doc._id instanceof ObjectId ? doc._id : new ObjectId(doc._id) + + const images: SpeciesImage[] = Array.isArray(doc.images) ? doc.images : [] + const commonNames: SpeciesCommonName[] = Array.isArray(doc.commonNames) ? doc.commonNames : [] + const altScientificNames: string[] = Array.isArray(doc.altScientificNames) ? doc.altScientificNames : [] + + const trefleId: number | null = + (typeof doc.sourceId_trefle === 'number' ? doc.sourceId_trefle : null) ?? + (typeof doc.trefleId === 'number' ? doc.trefleId : null) ?? + null + + const primaryImage = + images.find((img: SpeciesImage) => img.source === 'trefle') ?? + images[0] ?? + null + + const primaryCommonName = + (commonNames[0]?.name as string | undefined) ?? + (typeof doc.commonName === 'string' ? doc.commonName : undefined) ?? + (typeof doc.scientificName === 'string' ? doc.scientificName : undefined) ?? + null + + const synonyms: string[] | null = + (Array.isArray(doc.synonyms) ? doc.synonyms : null) ?? + (altScientificNames.length ? altScientificNames : null) + + const source: SpeciesSource = + doc.source === 'plantNet' ? 'trefle' // old model only allowed 'trefle' + : 'trefle' + + const updateDoc = { + $set: { + source, + sourceId: trefleId, + imageUrl: primaryImage ? primaryImage.url : (typeof doc.imageUrl === 'string' ? doc.imageUrl : null), + commonName: primaryCommonName, + scientificName: typeof doc.scientificName === 'string' ? doc.scientificName : null, + familyCommonName: typeof doc.familyCommonName === 'string' ? doc.familyCommonName : null, + synonyms, + genus: typeof doc.genus === 'string' ? doc.genus : null, + family: typeof doc.family === 'string' ? doc.family : null, + fertilizerSuggestions: typeof doc.fertilizerSuggestions === 'string' ? doc.fertilizerSuggestions : null, + }, + $unset: { + images: '', + commonNames: '', + altScientificNames: '', + sourceId_trefle: '', + sourceId_plantNet: '', + trefleId: '', + plantNetId: '', + }, + } + + if (!dryRun) { + await collection.updateOne({ _id: id }, updateDoc) + .catch((err) => { + console.log('Failed to run the following command:') + console.dir({ update: { where: { _id: id.toString() }, from: doc, to: updateDoc } }, { depth: null }) + + console.log('With the following error:') + console.error(err) + + process.exit(1) + }) + } + updated += 1 + } + + // Restore the old unique index so the collection matches the pre-migration schema. + if (!dryRun) { + const indexes = await collection.indexes() + const hasOldIndex = indexes.some( + (idx) => + idx.key && 'source' in idx.key && 'sourceId' in idx.key && idx.key.source === 1 && idx.key.sourceId === 1 + ) + if (!hasOldIndex) { + await collection.createIndex(OLD_SPECIES_INDEX, { unique: true }) + // eslint-disable-next-line no-console + console.log('Created old Species index: source_1_sourceId_1 (unique)') + } + } + + if (dryRun) { + // eslint-disable-next-line no-console + console.log('--DRY RUN ONLY, NO DATA ALTERED--') + } + // eslint-disable-next-line no-console + console.log(`DOWN complete. dryRun=${dryRun}. Updated docs=${updated}.`) +} + +async function main() { + loadEnv() + + const dryRun = process.argv.includes('--dry-run') || process.env.DRY_RUN === '1' + const isDown = process.argv.includes('--down') + + const { config } = await import('../config') + + if (!config.mongo.uri) { + throw new Error('Mongo config missing: config.mongo.uri is empty') + } + + const { mongo } = await import('../db') + + await mongo.connect() + + if (isDown) { + // eslint-disable-next-line no-console + console.log(`Running DOWN migration for Species (dryRun=${dryRun})...`) + await migrateDown(dryRun) + } else { + // eslint-disable-next-line no-console + console.log(`Running UP migration for Species (dryRun=${dryRun})...`) + await migrateUp(dryRun) + } + + await mongo.disconnect() +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err) + process.exit(1) +}) diff --git a/apps/api/src/services/fertilizerRecommendations/index.ts b/apps/api/src/services/fertilizerRecommendations/index.ts index 9839b9e..8520565 100644 --- a/apps/api/src/services/fertilizerRecommendations/index.ts +++ b/apps/api/src/services/fertilizerRecommendations/index.ts @@ -370,8 +370,9 @@ const systemPrompt = `You are a helpful plant care expert specializing in fertil const generatePlantInfoForUserPrompt = (species: ISpecies) => { // Build species information for user prompt + const primaryCommonName = species.commonNames?.[0]?.name return [ - species.commonName && `Common name: ${species.commonName}`, + primaryCommonName && `Common name: ${primaryCommonName}`, species.scientificName && `Scientific name: ${species.scientificName}`, species.genus && `Genus: ${species.genus}`, species.family && `Family: ${species.family}`, diff --git a/apps/api/src/services/speciesLookup/index.ts b/apps/api/src/services/speciesLookup/index.ts index e299056..b991d94 100644 --- a/apps/api/src/services/speciesLookup/index.ts +++ b/apps/api/src/services/speciesLookup/index.ts @@ -1,33 +1,148 @@ import * as trefleProvider from './providers/trefle' -export type Species = { - source: 'trefle', - sourceId: number, - imageUrl: string | null, - commonName: string, - scientificName: string | null, - familyCommonName: string | null, - synonyms: string[] | null, +import { Species } from '../../models' +import type { SpeciesCommonName, SpeciesImage } from '../../models/Species' + +type SpeciesLookupResult = { + sourceId_trefle?: number | null, + sourceId_plantNet?: string | null, + commonNames: SpeciesCommonName[], + images: SpeciesImage[], genus: string | null, family: string | null, + familyCommonName: string | null, + scientificName: string, + altScientificNames: string[], } -export const findSpecies = async ({ q }: { q?: string }): Promise => { +/** + * Find species from providers, persist/merge into the database by scientificName, and return + * the persisted records. Call this when you need a list of species (e.g. for search) that + * are stored in the DB and merged with any existing data. + */ +export const findAndCacheSpecies = async ({ q }: { q?: string }): Promise[]> => { const trefleSpecies = await trefleProvider.findSpecies({ q }) - const species = trefleSpecies - .filter(species => !q || species.common_name.toLowerCase().includes(q.toLowerCase()) || species.scientific_name?.toLowerCase().includes(q.toLowerCase()) || species.synonyms?.some(synonym => synonym.toLowerCase().includes(q.toLowerCase()))) - .map(species => ({ - source: 'trefle' as const, - sourceId: species.id, - imageUrl: species.image_url, - commonName: species.common_name, - scientificName: species.scientific_name, - familyCommonName: species.family_common_name, - synonyms: species.synonyms, - genus: species.genus, - family: species.family, + // Lookup species data from 3rd party providers + const lookupResults: SpeciesLookupResult[] = trefleSpecies + .map((species): SpeciesLookupResult | null => { + if (!species.scientific_name) return null + + return { + sourceId_trefle: species.id, + sourceId_plantNet: null, + images: species.image_url ? [{ url: species.image_url, source: 'trefle' as const }] : [], + commonNames: species.common_name ? [{ name: species.common_name, sources: ['trefle' as const] }] : [], + scientificName: species.scientific_name, + familyCommonName: species.family_common_name, + altScientificNames: species.synonyms ?? [], + genus: species.genus, + family: species.family, + } + }) + .filter((s): s is SpeciesLookupResult => s !== null) + .map(speciesData => ({ + ...speciesData, + altScientificNames: (speciesData.altScientificNames ?? []).map(s => s.trim()).filter(Boolean), + commonNames: (speciesData.commonNames ?? []) + .map(cn => ({ name: cn.name.trim(), sources: cn.sources ?? [] })) + .filter(cn => !!cn.name), + images: (speciesData.images ?? []) + .map(img => ({ url: img.url.trim(), source: img.source })) + .filter(img => !!img.url), })) - return species + const speciesPromises = lookupResults.map(async (speciesData) => { + const scientificName = normalizeScientificName(speciesData.scientificName) + const existing = await Species.findOne({ scientificName }) + + // Create a new species record if it doesn't exist + if (!existing) { + const created = await Species.create({ + scientificName, + sourceId_trefle: speciesData.sourceId_trefle ?? null, + sourceId_plantNet: speciesData.sourceId_plantNet ?? null, + images: speciesData.images, + commonNames: speciesData.commonNames, + familyCommonName: speciesData.familyCommonName, + altScientificNames: speciesData.altScientificNames, + genus: speciesData.genus, + family: speciesData.family, + }) + + return toPersistedSpecies(created) + } + + // Merge the incoming data with the existing data + const merged = mergeExistingAndIncomingSpeciesData(existing, speciesData) + + // Update the existing species record if it has been modified + if (merged.modifiedPaths().length > 0) { + await merged.save() + } + + return toPersistedSpecies(merged) + }) + + return Promise.all(speciesPromises) +} + +const mergeExistingAndIncomingSpeciesData = (existing: InstanceType, speciesData: SpeciesLookupResult) => { + const existingImages = existing.images ?? [] + const imageKey = (img: { url: string, source: string }) => `${img.source}:${normalizeLooseKey(img.url)}` + const mergedImagesMap = new Map(existingImages.map(img => [imageKey(img), img])) + for (const img of speciesData.images) { + const key = imageKey(img) + if (!mergedImagesMap.has(key)) mergedImagesMap.set(key, img) + } + const mergedImages = Array.from(mergedImagesMap.values()) + + const existingCommonNames = existing.commonNames ?? [] + const commonNameKey = (name: string) => normalizeLooseKey(name) + const mergedCommonNamesMap = new Map(existingCommonNames.map(cn => [commonNameKey(cn.name), cn])) + for (const cn of speciesData.commonNames) { + const key = commonNameKey(cn.name) + const current = mergedCommonNamesMap.get(key) + if (!current) { + mergedCommonNamesMap.set(key, { name: cn.name, sources: Array.from(new Set(cn.sources)) }) + } else { + const mergedSources = Array.from(new Set([...(current.sources ?? []), ...(cn.sources ?? [])])) + current.name = current.name || cn.name + current.sources = mergedSources + } + } + const mergedCommonNames = Array.from(mergedCommonNamesMap.values()) + + const mergedAltNames = Array.from( + new Set([...(existing.altScientificNames ?? []), ...speciesData.altScientificNames].map(s => s.trim()).filter(Boolean)) + ) + + const scientificName = normalizeScientificName(speciesData.scientificName) + + const merged = existing + + merged.sourceId_trefle = existing.sourceId_trefle ?? speciesData.sourceId_trefle ?? null + merged.sourceId_plantNet = existing.sourceId_plantNet ?? speciesData.sourceId_plantNet ?? null + merged.images = mergedImages + merged.commonNames = mergedCommonNames + merged.altScientificNames = mergedAltNames + merged.familyCommonName = existing.familyCommonName ?? speciesData.familyCommonName + merged.genus = existing.genus ?? speciesData.genus + merged.family = existing.family ?? speciesData.family + merged.scientificName = scientificName + + return merged +} + +const normalizeScientificName = (name: string) => { + const trimmed = name.trim().replace(/\s+/g, ' ') + if (!trimmed) return trimmed + + return trimmed[0]!.toUpperCase() + trimmed.slice(1).toLowerCase() +} + +const normalizeLooseKey = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase() + +function toPersistedSpecies(doc: InstanceType) { + return doc.toJSON() } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5e50c6a..7871a3f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@plannting/mobile", - "version": "0.13.0", + "version": "0.14.0", "private": true, "main": "index.ts", "scripts": { diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index 3dd343b..c8ba7c1 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -43,7 +43,7 @@ export function FertilizersScreen() { const [filterModalVisible, setFilterModalVisible] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') const [includeDeletedItems, setIncludeDeletedItems] = React.useState(false) - const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) + const debouncedSearchQuery = useDebounce(searchQuery.trim()) const [addFormData, setAddFormData] = React.useState({ name: '', diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 5f8e358..2d3c410 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -37,7 +37,7 @@ function ToDoScreen() { const [filterModalVisible, setFilterModalVisible] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [includeDeletedItems, setIncludeDeletedItems] = useState(false) - const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) + const debouncedSearchQuery = useDebounce(searchQuery.trim()) const { data, diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index a5b4a29..83e63fe 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -35,7 +35,7 @@ export function PlantsScreen() { const [filterModalVisible, setFilterModalVisible] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') const [includeDeletedItems, setIncludeDeletedItems] = React.useState(false) - const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) + const debouncedSearchQuery = useDebounce(searchQuery.trim()) const { data, diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index d185f08..8e35440 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -11,17 +11,19 @@ import type { ISpecies } from '@plannting/api/dist/models/Species' import { DateTimePicker } from '../../components/DateTimePicker' import { FertilizerRecommendations } from '../../components/FertilizerRecommendations' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' +import { PlantCommonNames } from '../../components/PlantCommonNames' +import { PlantIdentificationModal } from '../../components/PlantIdentificationModal' +import { PlantLifecycleChangeModal } from '../../components/PlantLifecycleChangeModal' import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SegmentedControl } from '../../components/SegmentedControl' -import { PlantIdentificationModal } from '../../components/PlantIdentificationModal' -import { PlantLifecycleChangeModal } from '../../components/PlantLifecycleChangeModal' import { SpeciesCard } from '../../components/SpeciesCard' import { TextInput } from '../../components/TextInput' import { useAlert } from '../../contexts/AlertContext' import { useAuth } from '../../contexts/AuthContext' +import { useDebounce } from '../../hooks/useDebounce' import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' import { trpc } from '../../trpc' @@ -125,9 +127,10 @@ export function AddEditPlantScreen() { staleTime: 0, // Prevent cache from other queries from being used, otherwise pending changes in form data will be lost }) + const debouncedSpeciesQuery = useDebounce(speciesSearchQuery.trim()) const { data: speciesData, isLoading: isLoadingSpecies } = trpc.species.list.useQuery( - { q: speciesSearchQuery || undefined }, - { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } + { q: debouncedSpeciesQuery || undefined }, + { enabled: debouncedSpeciesQuery.length > 2 && showSpeciesSuggestions } ) useRefreshOnFocus(() => { @@ -233,7 +236,8 @@ export function AddEditPlantScreen() { 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 + const primaryCommonName = species.commonNames?.[0]?.name + const newName = formData.name.trim() === '' ? (primaryCommonName ?? formData.name) : formData.name setFormData(formData => ({ ...formData, name: newName, speciesId: species._id })) setShowNameInput(true) @@ -588,7 +592,7 @@ export function AddEditPlantScreen() { position: 'absolute', right: 12, top: 0, - bottom: 14, + bottom: 30, justifyContent: 'center', alignItems: 'center', }}> @@ -635,18 +639,20 @@ export function AddEditPlantScreen() { }} onPress={() => handleSpeciesSelect(item)} > - {item.imageUrl && ( + {item.images?.[0]?.url && ( )} - {item.commonName} - {item.scientificName && ( - {item.scientificName} - )} + cn.name)} + primaryNameStyle={[styles.listItemTitle, { marginBottom: 4 }]} + additionalNameStyle={[styles.listItemTitle, { marginBottom: 4 }]} + /> + {item.scientificName} {(item.family || item.genus) && ( {[item.genus, item.family].filter(Boolean).join(' • ')} diff --git a/apps/mobile/src/components/PlantCommonNames.tsx b/apps/mobile/src/components/PlantCommonNames.tsx new file mode 100644 index 0000000..5bdb061 --- /dev/null +++ b/apps/mobile/src/components/PlantCommonNames.tsx @@ -0,0 +1,65 @@ +import React, { useMemo, useState } from 'react' +import { Text, type TextStyle } from 'react-native' + +import { palette } from '../styles' + +const NUM_NAMES_TO_SHOW_UNEXPANDED = 1 + +export function PlantCommonNames({ + names, + primaryNameStyle, + additionalNameStyle, + akaPrefix = 'aka ', +}: { + names: string[] | null | undefined, + primaryNameStyle?: TextStyle | TextStyle[], + additionalNameStyle?: TextStyle | TextStyle[], + akaPrefix?: string, +}) { + const [expanded, setExpanded] = useState(false) + + const cleaned = useMemo(() => { + const list = (names ?? []) + .map(n => n.trim()) + .filter(Boolean) + + // de-dupe (case-insensitive) while keeping order + const seen = new Set() + const out: string[] = [] + for (const n of list) { + const key = n.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(n) + } + return out + }, [names]) + + if (cleaned.length === 0) return null + + const first = cleaned[0]! + const rest = cleaned.slice(1) + const previewRest = rest.slice(0, NUM_NAMES_TO_SHOW_UNEXPANDED) + const hiddenRest = rest.slice(NUM_NAMES_TO_SHOW_UNEXPANDED) + + const shownRest = expanded ? rest : previewRest + const showMore = !expanded && hiddenRest.length > 0 + + return ( + <> + {first} + {shownRest.map((n) => ( + {akaPrefix}{n} + ))} + {showMore && ( + setExpanded(true)} + style={{ color: palette.primary, fontWeight: '600' }} + suppressHighlighting + > + more... + + )} + + ) +} diff --git a/apps/mobile/src/components/PlantIdentificationModal.tsx b/apps/mobile/src/components/PlantIdentificationModal.tsx index fcd0591..e90fb77 100644 --- a/apps/mobile/src/components/PlantIdentificationModal.tsx +++ b/apps/mobile/src/components/PlantIdentificationModal.tsx @@ -9,6 +9,7 @@ import { trpc } from '../trpc' import { palette, styles } from '../styles' import { ImageGallery, type ImageGalleryItem } from './ImageGallery' +import { PlantCommonNames } from './PlantCommonNames' import { ShimmerText } from './ShimmerText' import { TextInput } from './TextInput' @@ -34,9 +35,9 @@ type IdentifyResult = { type SpeciesListItem = { _id: string, - commonName: string, - imageUrl?: string | null, - scientificName?: string | null, + commonNames: Array<{ name: string, sources: Array<'trefle' | 'plantNet'> }>, + images: Array<{ url: string, source: 'trefle' | 'plantNet' }>, + scientificName: string, } export function PlantIdentificationModal({ @@ -66,7 +67,7 @@ export function PlantIdentificationModal({ setGalleryItems(null) } - const debouncedManualQuery = useDebounce(manualSearchQuery.trim(), 300) + const debouncedManualQuery = useDebounce(manualSearchQuery.trim()) const hasTriggeredManualSearch = visible && checkedIndex === 'manual' && debouncedManualQuery.length > 2 const { data: modalSpeciesData, isFetching: isFetchingModalSpecies } = trpc.species.list.useQuery( { q: debouncedManualQuery || undefined }, @@ -284,19 +285,14 @@ export function PlantIdentificationModal({ )} - - {result.commonNames && result.commonNames.length > 0 ? result.commonNames[0] : result.scientificName} + + + {result.scientificName} - {result.commonNames && result.commonNames.length > 1 && result.commonNames.slice(1).map(commonName => ( - - aka {commonName} - - ))} - {result.commonNames && result.commonNames.length > 0 && ( - - {result.scientificName} - - )} Confidence: {(result.confidence * 100).toFixed(1)}% @@ -373,16 +369,16 @@ export function PlantIdentificationModal({ onPress={() => setSelectedManualSpecies(item)} activeOpacity={0.7} > - {item.imageUrl ? ( + {item.images?.[0]?.url ? ( openGallery([{ url: item.imageUrl!, caption: 'Species' }])} + onPress={() => openGallery(toGalleryItems(item.images))} style={{ marginRight: 16 }} activeOpacity={0.7} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > @@ -392,12 +388,12 @@ export function PlantIdentificationModal({ )} - - {item.commonName} - - {item.scientificName && ( - {item.scientificName} - )} + cn.name)} + primaryNameStyle={{ fontSize: 16, fontWeight: '600', marginBottom: 4, color: palette.textPrimary }} + additionalNameStyle={{ fontSize: 14, marginBottom: 4, color: palette.textPrimary }} + /> + {item.scientificName} | undefined): ImageGalleryItem[] { +function toGalleryItems(images: Array<{ url: string, organ?: string, source?: string }> | undefined): ImageGalleryItem[] { if (!images?.length) return [] return images.map(img => ({ url: img.url, - caption: img.organ, + caption: img.organ ?? img.source, })) } diff --git a/apps/mobile/src/components/SpeciesCard.tsx b/apps/mobile/src/components/SpeciesCard.tsx index 0119252..ce4e406 100644 --- a/apps/mobile/src/components/SpeciesCard.tsx +++ b/apps/mobile/src/components/SpeciesCard.tsx @@ -1,11 +1,14 @@ -import React from 'react' -import { Text, TouchableOpacity, View } from 'react-native' +import React, { useMemo, useState } from 'react' +import { Modal, Text, TouchableOpacity, View } from 'react-native' import type { ISpecies } from '@plannting/api/dist/models/Species' -import { Image } from './Image' import { palette, styles } from '../styles' +import { Image } from './Image' +import { ImageGallery, type ImageGalleryItem } from './ImageGallery' +import { PlantCommonNames } from './PlantCommonNames' + export function SpeciesCard({ onClose = undefined, species, @@ -13,6 +16,22 @@ export function SpeciesCard({ onClose?: () => void, species: ISpecies, }) { + const [isGalleryOpen, setIsGalleryOpen] = useState(false) + + const images: ImageGalleryItem[] = useMemo(() => { + const imgs = (species.images ?? []) + .map(img => ({ url: img.url })) + .filter(i => !!i.url) + return imgs + }, [species.images]) + + const primaryImageUrl = images[0]?.url ?? null + + const commonNames = useMemo( + () => (species.commonNames ?? []).map(cn => cn.name).filter(Boolean), + [species.commonNames] + ) + return ( - {species.imageUrl && ( - + setIsGalleryOpen(false)}> + setIsGalleryOpen(false)} /> + + + {primaryImageUrl && ( + images.length > 0 && setIsGalleryOpen(true)} + activeOpacity={0.8} + style={{ marginRight: 12 }} + > + + + + )} - {species.commonName} + {species.scientificName && ( - {species.scientificName} + {species.scientificName} )} diff --git a/apps/mobile/src/hooks/useDebounce.ts b/apps/mobile/src/hooks/useDebounce.ts index df53e11..fd491e9 100644 --- a/apps/mobile/src/hooks/useDebounce.ts +++ b/apps/mobile/src/hooks/useDebounce.ts @@ -4,9 +4,9 @@ import { useEffect, useState } from 'react' * Debounce any changing value. * * Example: - * const debouncedSearch = useDebounce(search, 300) + * const debouncedSearch = useDebounce(search) */ -export function useDebounce(value: T, delayMs = 300) { +export function useDebounce(value: T, delayMs = 500) { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { diff --git a/apps/mobile/src/utils/plantPhoto.ts b/apps/mobile/src/utils/plantPhoto.ts index b396b4e..02800cd 100644 --- a/apps/mobile/src/utils/plantPhoto.ts +++ b/apps/mobile/src/utils/plantPhoto.ts @@ -169,12 +169,12 @@ export function getPlantPhotoImageSource( plant, }: { photoUrl?: string, - plant?: { _id: string, photoUrl?: string | null, species?: { imageUrl?: string | null } | null, updatedAt?: Date | string }, + plant?: { _id: string, photoUrl?: string | null, species?: { images?: Array<{ url: string }> | null } | null, updatedAt?: Date | string }, }, options: { token: string | null }, ): { uri: string, headers?: { Authorization: string } } | null { const plantPhotoUrl = photoUrlProp ?? plant?.photoUrl ?? null - const speciesPhotoUrl = plant?.species?.imageUrl ?? null + const speciesPhotoUrl = plant?.species?.images?.[0]?.url ?? null if (!plantPhotoUrl && !speciesPhotoUrl) return null diff --git a/package.json b/package.json index 4258dea..9783bd2 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "test:e2e:start-api": "tsx scripts/testApiServer.ts", "test:e2e:start-mobile": "cross-env EXPO_PUBLIC_CYPRESS=1 npm run dev:mobile", "cypress:run": "cypress run", - "cypress:open": "cypress open" + "cypress:open": "cypress open", + "dbBackup": "npm run dbBackup --workspace=apps/api", + "dbRestore": "npm run dbRestore --workspace=apps/api" }, "devDependencies": { "concurrently": "^8.2.2",