diff --git a/plant-manager-importer/Program.cs b/plant-manager-importer/Program.cs index 7c65c87..a0056c6 100644 --- a/plant-manager-importer/Program.cs +++ b/plant-manager-importer/Program.cs @@ -151,7 +151,7 @@ static IEnumerable ReadGbifDwcaRecords( var rank = layout.Taxon.Value(fields, "taxonRank"); var status = layout.Taxon.Value(fields, "taxonomicStatus"); if (!EqualsIgnoreCase(kingdom, "Plantae") || - !EqualsIgnoreCase(rank, "species") || + !IsSpeciesOrBelowRank(rank) || !EqualsIgnoreCase(status, "accepted")) { continue; @@ -296,6 +296,20 @@ static bool IsEnglishOrUnknown(string? language) => EqualsIgnoreCase(language, "eng") || EqualsIgnoreCase(language, "english"); +static bool IsSpeciesOrBelowRank(string? rank) +{ + var normalized = rank?.Trim().Replace(" ", "_").Replace("-", "_").ToLowerInvariant(); + return normalized is + "species" or + "subspecies" or + "variety" or + "subvariety" or + "form" or + "forma" or + "subform" or + "subforma"; +} + static async Task CreateSchemaAsync(SqliteConnection connection) { await ExecuteAsync(connection, """ diff --git a/plant-manager-web/src/components/TaxaView.tsx b/plant-manager-web/src/components/TaxaView.tsx index df7b220..5827cb2 100644 --- a/plant-manager-web/src/components/TaxaView.tsx +++ b/plant-manager-web/src/components/TaxaView.tsx @@ -30,13 +30,6 @@ type TaxaViewProps = { onSearchQueryChange: (value: string) => void; }; -const plantInfoSearchExamples = [ - 'ficus', - 'monstera', - 'alocasia', - 'croton', -]; - function getPlantInfoSubtitle(result: PlantInfoSearchResult) { if (result.commonName) { return result.commonName; @@ -116,19 +109,6 @@ export function TaxaView({ -
- {plantInfoSearchExamples.map((query) => ( - - ))} -
- {plantInfoResults.length > 0 ? (
{plantInfoResults.map((result) => ( @@ -137,6 +117,10 @@ export function TaxaView({

{result.canonicalName ?? result.scientificName}

{getPlantInfoSubtitle(result)}

+
+
Common Names
+
{result.commonNames.length > 0 ? result.commonNames.join(', ') : 'None'}
+
Family
{result.family ?? 'Unknown'}
@@ -155,7 +139,19 @@ export function TaxaView({
Source
-
{`${result.source}:${result.externalId}`}
+
+ {result.source === 'gbif' ? ( + + {`${result.source}:${result.externalId}`} + + ) : ( + `${result.source}:${result.externalId}` + )} +
diff --git a/plant-manager-web/src/domain.ts b/plant-manager-web/src/domain.ts index 269c004..51255cf 100644 --- a/plant-manager-web/src/domain.ts +++ b/plant-manager-web/src/domain.ts @@ -66,6 +66,7 @@ export type PlantInfoSearchResult = { family: string | null; genus: string | null; species: string | null; + commonNames: string[]; }; export type PlantLocation = { diff --git a/plant-manager-web/src/styles.css b/plant-manager-web/src/styles.css index 229276a..df27ff0 100644 --- a/plant-manager-web/src/styles.css +++ b/plant-manager-web/src/styles.css @@ -757,31 +757,6 @@ dd { gap: 10px; } -.query-chip-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 8px 0 12px; -} - -.query-chip { - min-height: 30px; - padding: 0 10px; - border: 1px solid #cfc4b4; - border-radius: 999px; - background: var(--field); - color: #33463b; - font-size: 0.78rem; - font-weight: 600; - cursor: pointer; -} - -.query-chip:hover { - border-color: var(--link); - background: var(--sage); - color: var(--link); -} - .plant-info-result { min-width: 0; } diff --git a/plant-manager-web/src/use-app-actions.ts b/plant-manager-web/src/use-app-actions.ts index ab90fd9..f183aeb 100644 --- a/plant-manager-web/src/use-app-actions.ts +++ b/plant-manager-web/src/use-app-actions.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { assignPlantFlag, completeCareTasksBulk, @@ -56,6 +56,9 @@ import { import type { useAppEditors } from './use-app-editors'; import type { useDashboardData } from './use-dashboard-data'; +const plantInfoSearchDebounceMs = 150; +const plantInfoSearchMinLength = 2; + type Editors = ReturnType; type DashboardData = ReturnType; @@ -90,6 +93,39 @@ export function useAppActions({ const [plantInfoQuery, setPlantInfoQuery] = useState(''); const [plantInfoResults, setPlantInfoResults] = useState([]); const [hasSearchedPlantInfo, setHasSearchedPlantInfo] = useState(false); + const latestPlantInfoSearchId = useRef(0); + const lastCompletedPlantInfoQuery = useRef(''); + + useEffect(() => { + const query = plantInfoQuery.trim(); + if (query.length === 0) { + latestPlantInfoSearchId.current += 1; + lastCompletedPlantInfoQuery.current = ''; + setPlantInfoResults([]); + setHasSearchedPlantInfo(false); + setIsSearchingPlantInfo(false); + return; + } + + if (query.length < plantInfoSearchMinLength) { + latestPlantInfoSearchId.current += 1; + lastCompletedPlantInfoQuery.current = ''; + setPlantInfoResults([]); + setHasSearchedPlantInfo(false); + setIsSearchingPlantInfo(false); + return; + } + + if (query !== lastCompletedPlantInfoQuery.current) { + setIsSearchingPlantInfo(true); + } + + const timeout = window.setTimeout(() => { + void searchTaxonInfo(query, { updateQuery: false }); + }, plantInfoSearchDebounceMs); + + return () => window.clearTimeout(timeout); + }, [plantInfoQuery]); async function savePlant() { if (!editors.form.nickname.trim()) { @@ -211,24 +247,44 @@ export function useAppActions({ } } - async function searchTaxonInfo(queryOverride?: string) { + async function searchTaxonInfo( + queryOverride?: string, + options: { updateQuery?: boolean } = {}, + ) { const query = queryOverride ?? plantInfoQuery; - setPlantInfoQuery(query); + if (options.updateQuery ?? true) { + setPlantInfoQuery(query); + } - if (query.trim().length < 2) { + const trimmedQuery = query.trim(); + if (trimmedQuery.length < plantInfoSearchMinLength) { setError('Search needs at least 2 characters.'); return; } + if (trimmedQuery === lastCompletedPlantInfoQuery.current) { + return; + } + + const searchId = latestPlantInfoSearchId.current + 1; + latestPlantInfoSearchId.current = searchId; setIsSearchingPlantInfo(true); try { setError(null); setHasSearchedPlantInfo(true); - setPlantInfoResults(await searchPlantInfo(query)); + const results = await searchPlantInfo(trimmedQuery); + if (latestPlantInfoSearchId.current === searchId) { + lastCompletedPlantInfoQuery.current = trimmedQuery; + setPlantInfoResults(results); + } } catch { - setError('Could not search offline plant info.'); + if (latestPlantInfoSearchId.current === searchId) { + setError('Could not search offline plant info.'); + } } finally { - setIsSearchingPlantInfo(false); + if (latestPlantInfoSearchId.current === searchId) { + setIsSearchingPlantInfo(false); + } } } diff --git a/plant-manager/Services/PlantInfoSearchService.cs b/plant-manager/Services/PlantInfoSearchService.cs index b24f1a1..f787144 100644 --- a/plant-manager/Services/PlantInfoSearchService.cs +++ b/plant-manager/Services/PlantInfoSearchService.cs @@ -37,6 +37,7 @@ namespace plant_manager.Services r.Family, r.Genus, r.Species, + r.AliasesText, CASE WHEN lower(coalesce(r.CommonName, '')) = $normalized THEN 0 WHEN lower(coalesce(r.CanonicalName, '')) = $normalized THEN 1 @@ -88,7 +89,8 @@ namespace plant_manager.Services ReadNullableString(reader, 6), ReadNullableString(reader, 7), ReadNullableString(reader, 8), - ReadNullableString(reader, 9))); + ReadNullableString(reader, 9), + ToCommonNames(ReadNullableString(reader, 10)))); } return results; @@ -120,6 +122,20 @@ namespace plant_manager.Services private static string? ReadNullableString(IDataRecord reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + + private static IReadOnlyList ToCommonNames(string? aliasesText) + { + if (string.IsNullOrWhiteSpace(aliasesText)) + { + return []; + } + + return aliasesText + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToList(); + } } public record PlantInfoSearchResultDto( @@ -132,5 +148,6 @@ namespace plant_manager.Services string? Status, string? Family, string? Genus, - string? Species); + string? Species, + IReadOnlyList CommonNames); }