clean up plant search

This commit is contained in:
2026-06-09 02:09:41 +00:00
parent 7588a23f34
commit 8725a387bf
6 changed files with 115 additions and 56 deletions
+15 -1
View File
@@ -151,7 +151,7 @@ static IEnumerable<PlantInfoImportRecord> ReadGbifDwcaRecords(
var rank = layout.Taxon.Value(fields, "taxonRank"); var rank = layout.Taxon.Value(fields, "taxonRank");
var status = layout.Taxon.Value(fields, "taxonomicStatus"); var status = layout.Taxon.Value(fields, "taxonomicStatus");
if (!EqualsIgnoreCase(kingdom, "Plantae") || if (!EqualsIgnoreCase(kingdom, "Plantae") ||
!EqualsIgnoreCase(rank, "species") || !IsSpeciesOrBelowRank(rank) ||
!EqualsIgnoreCase(status, "accepted")) !EqualsIgnoreCase(status, "accepted"))
{ {
continue; continue;
@@ -296,6 +296,20 @@ static bool IsEnglishOrUnknown(string? language) =>
EqualsIgnoreCase(language, "eng") || EqualsIgnoreCase(language, "eng") ||
EqualsIgnoreCase(language, "english"); 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) static async Task CreateSchemaAsync(SqliteConnection connection)
{ {
await ExecuteAsync(connection, """ await ExecuteAsync(connection, """
+17 -21
View File
@@ -30,13 +30,6 @@ type TaxaViewProps = {
onSearchQueryChange: (value: string) => void; onSearchQueryChange: (value: string) => void;
}; };
const plantInfoSearchExamples = [
'ficus',
'monstera',
'alocasia',
'croton',
];
function getPlantInfoSubtitle(result: PlantInfoSearchResult) { function getPlantInfoSubtitle(result: PlantInfoSearchResult) {
if (result.commonName) { if (result.commonName) {
return result.commonName; return result.commonName;
@@ -116,19 +109,6 @@ export function TaxaView({
</button> </button>
</div> </div>
<div className="query-chip-row" aria-label="Example plant info searches">
{plantInfoSearchExamples.map((query) => (
<button
className="query-chip"
type="button"
key={query}
onClick={() => onSearch(query)}
>
{query}
</button>
))}
</div>
{plantInfoResults.length > 0 ? ( {plantInfoResults.length > 0 ? (
<div className="plant-list"> <div className="plant-list">
{plantInfoResults.map((result) => ( {plantInfoResults.map((result) => (
@@ -137,6 +117,10 @@ export function TaxaView({
<h3>{result.canonicalName ?? result.scientificName}</h3> <h3>{result.canonicalName ?? result.scientificName}</h3>
<p>{getPlantInfoSubtitle(result)}</p> <p>{getPlantInfoSubtitle(result)}</p>
<dl className="plant-info-meta"> <dl className="plant-info-meta">
<div>
<dt>Common Names</dt>
<dd>{result.commonNames.length > 0 ? result.commonNames.join(', ') : 'None'}</dd>
</div>
<div> <div>
<dt>Family</dt> <dt>Family</dt>
<dd>{result.family ?? 'Unknown'}</dd> <dd>{result.family ?? 'Unknown'}</dd>
@@ -155,7 +139,19 @@ export function TaxaView({
</div> </div>
<div> <div>
<dt>Source</dt> <dt>Source</dt>
<dd>{`${result.source}:${result.externalId}`}</dd> <dd>
{result.source === 'gbif' ? (
<a
href={`https://www.gbif.org/species/${result.externalId}`}
target="_blank"
rel="noreferrer"
>
{`${result.source}:${result.externalId}`}
</a>
) : (
`${result.source}:${result.externalId}`
)}
</dd>
</div> </div>
</dl> </dl>
</div> </div>
+1
View File
@@ -66,6 +66,7 @@ export type PlantInfoSearchResult = {
family: string | null; family: string | null;
genus: string | null; genus: string | null;
species: string | null; species: string | null;
commonNames: string[];
}; };
export type PlantLocation = { export type PlantLocation = {
-25
View File
@@ -757,31 +757,6 @@ dd {
gap: 10px; 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 { .plant-info-result {
min-width: 0; min-width: 0;
} }
+63 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
assignPlantFlag, assignPlantFlag,
completeCareTasksBulk, completeCareTasksBulk,
@@ -56,6 +56,9 @@ import {
import type { useAppEditors } from './use-app-editors'; import type { useAppEditors } from './use-app-editors';
import type { useDashboardData } from './use-dashboard-data'; import type { useDashboardData } from './use-dashboard-data';
const plantInfoSearchDebounceMs = 150;
const plantInfoSearchMinLength = 2;
type Editors = ReturnType<typeof useAppEditors>; type Editors = ReturnType<typeof useAppEditors>;
type DashboardData = ReturnType<typeof useDashboardData>; type DashboardData = ReturnType<typeof useDashboardData>;
@@ -90,6 +93,39 @@ export function useAppActions({
const [plantInfoQuery, setPlantInfoQuery] = useState(''); const [plantInfoQuery, setPlantInfoQuery] = useState('');
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]); const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]);
const [hasSearchedPlantInfo, setHasSearchedPlantInfo] = useState(false); 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() { async function savePlant() {
if (!editors.form.nickname.trim()) { 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; 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.'); setError('Search needs at least 2 characters.');
return; return;
} }
if (trimmedQuery === lastCompletedPlantInfoQuery.current) {
return;
}
const searchId = latestPlantInfoSearchId.current + 1;
latestPlantInfoSearchId.current = searchId;
setIsSearchingPlantInfo(true); setIsSearchingPlantInfo(true);
try { try {
setError(null); setError(null);
setHasSearchedPlantInfo(true); setHasSearchedPlantInfo(true);
setPlantInfoResults(await searchPlantInfo(query)); const results = await searchPlantInfo(trimmedQuery);
if (latestPlantInfoSearchId.current === searchId) {
lastCompletedPlantInfoQuery.current = trimmedQuery;
setPlantInfoResults(results);
}
} catch { } catch {
setError('Could not search offline plant info.'); if (latestPlantInfoSearchId.current === searchId) {
setError('Could not search offline plant info.');
}
} finally { } finally {
setIsSearchingPlantInfo(false); if (latestPlantInfoSearchId.current === searchId) {
setIsSearchingPlantInfo(false);
}
} }
} }
@@ -37,6 +37,7 @@ namespace plant_manager.Services
r.Family, r.Family,
r.Genus, r.Genus,
r.Species, r.Species,
r.AliasesText,
CASE CASE
WHEN lower(coalesce(r.CommonName, '')) = $normalized THEN 0 WHEN lower(coalesce(r.CommonName, '')) = $normalized THEN 0
WHEN lower(coalesce(r.CanonicalName, '')) = $normalized THEN 1 WHEN lower(coalesce(r.CanonicalName, '')) = $normalized THEN 1
@@ -88,7 +89,8 @@ namespace plant_manager.Services
ReadNullableString(reader, 6), ReadNullableString(reader, 6),
ReadNullableString(reader, 7), ReadNullableString(reader, 7),
ReadNullableString(reader, 8), ReadNullableString(reader, 8),
ReadNullableString(reader, 9))); ReadNullableString(reader, 9),
ToCommonNames(ReadNullableString(reader, 10))));
} }
return results; return results;
@@ -120,6 +122,20 @@ namespace plant_manager.Services
private static string? ReadNullableString(IDataRecord reader, int ordinal) => private static string? ReadNullableString(IDataRecord reader, int ordinal) =>
reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
private static IReadOnlyList<string> 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( public record PlantInfoSearchResultDto(
@@ -132,5 +148,6 @@ namespace plant_manager.Services
string? Status, string? Status,
string? Family, string? Family,
string? Genus, string? Genus,
string? Species); string? Species,
IReadOnlyList<string> CommonNames);
} }