add care workflow and schedule management

This commit is contained in:
2026-06-14 22:37:22 +00:00
parent 65fa730d3d
commit cc6f40222e
36 changed files with 2543 additions and 1115 deletions
+3
View File
@@ -7,6 +7,9 @@
.env .env
.codex/ .codex/
data/ data/
plant-manager/App_Data/
core.*
**/core.*
# User-specific files # User-specific files
*.rsuser *.rsuser
+37 -26
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { ActionsView } from './components/ActionsView'; import { ActionsView } from './components/ActionsView';
import { ActivitiesView } from './components/ActivitiesView'; import { ActivitiesView } from './components/ActivitiesView';
import { CareView } from './components/CareView';
import { FlagsView } from './components/FlagsView'; import { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView'; import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView'; import { HomeView } from './components/HomeView';
@@ -35,6 +36,7 @@ export function App() {
loadPlantsAndCareTasks, loadPlantsAndCareTasks,
loadRecipesAndResources, loadRecipesAndResources,
loadTaxaAndPlants, loadTaxaAndPlants,
plantCareSchedules,
plantFlagDefinitions, plantFlagDefinitions,
plantGroups, plantGroups,
plantLocations, plantLocations,
@@ -64,7 +66,6 @@ export function App() {
activePlantGroup, activePlantGroup,
activeRecipe, activeRecipe,
activeResource, activeResource,
activeTaxon,
activityForm, activityForm,
bulkScheduleForm, bulkScheduleForm,
cancelEditing, cancelEditing,
@@ -75,7 +76,8 @@ export function App() {
cancelEditingPlantGroup, cancelEditingPlantGroup,
cancelEditingRecipe, cancelEditingRecipe,
cancelEditingResource, cancelEditingResource,
cancelEditingTaxon, cancelEditingSchedule,
editingScheduleId,
flagDefinitionForm, flagDefinitionForm,
form, form,
isActionEditorOpen, isActionEditorOpen,
@@ -86,7 +88,6 @@ export function App() {
isPlantGroupEditorOpen, isPlantGroupEditorOpen,
isRecipeEditorOpen, isRecipeEditorOpen,
isResourceEditorOpen, isResourceEditorOpen,
isTaxonEditorOpen,
locationForm, locationForm,
openPlantDetail, openPlantDetail,
plantFlagForm, plantFlagForm,
@@ -118,8 +119,6 @@ export function App() {
startAddingPlantGroup, startAddingPlantGroup,
startAddingRecipe, startAddingRecipe,
startAddingResource, startAddingResource,
startAddingTaxon,
startAddingTaxonFromPlantInfo,
startEditingAction, startEditingAction,
startEditingActivity, startEditingActivity,
startEditingFlagDefinition, startEditingFlagDefinition,
@@ -127,8 +126,8 @@ export function App() {
startEditingPlantGroup, startEditingPlantGroup,
startEditingRecipe, startEditingRecipe,
startEditingResource, startEditingResource,
startEditingTaxon, startEditingSchedule,
taxonForm, startNewSchedule,
updateActionForm, updateActionForm,
updateActivityForm, updateActivityForm,
updateBulkScheduleForm, updateBulkScheduleForm,
@@ -139,7 +138,6 @@ export function App() {
updatePlantGroupForm, updatePlantGroupForm,
updateRecipeForm, updateRecipeForm,
updateResourceForm, updateResourceForm,
updateTaxonForm,
} = editors; } = editors;
const { const {
assignFlagToSelectedPlant, assignFlagToSelectedPlant,
@@ -147,11 +145,13 @@ export function App() {
catalogImportResult, catalogImportResult,
completeBulkTasks, completeBulkTasks,
completeTask, completeTask,
dismissCare,
exportSpreadsheet, exportSpreadsheet,
isExporting, isExporting,
isImportingCatalog, isImportingCatalog,
isSearchingPlantInfo, isSearchingPlantInfo,
isSaving, isSaving,
logCare,
importTaxonFromPlantInfo, importTaxonFromPlantInfo,
hasSearchedPlantInfo, hasSearchedPlantInfo,
plantInfoQuery, plantInfoQuery,
@@ -159,7 +159,7 @@ export function App() {
removeAction, removeAction,
removeActivity, removeActivity,
removeAssignedPlantFlag, removeAssignedPlantFlag,
removeBulkSchedule, removeSchedule,
removeFlagDefinition, removeFlagDefinition,
removeLocation, removeLocation,
removePlant, removePlant,
@@ -177,7 +177,6 @@ export function App() {
savePlantGroup, savePlantGroup,
saveRecipe, saveRecipe,
saveResource, saveResource,
saveTaxon,
searchTaxonInfo, searchTaxonInfo,
previewCatalogImportFile, previewCatalogImportFile,
setPlantInfoQuery, setPlantInfoQuery,
@@ -238,6 +237,13 @@ export function App() {
<div className="nav-group"> <div className="nav-group">
<h3>Care</h3> <h3>Care</h3>
<button
type="button"
aria-current={view === 'care' ? 'page' : undefined}
onClick={() => setView('care')}
>
Care
</button>
<button <button
type="button" type="button"
aria-current={view === 'schedules' ? 'page' : undefined} aria-current={view === 'schedules' ? 'page' : undefined}
@@ -261,7 +267,7 @@ export function App() {
aria-current={view === 'taxa' ? 'page' : undefined} aria-current={view === 'taxa' ? 'page' : undefined}
onClick={() => setView('taxa')} onClick={() => setView('taxa')}
> >
Plant Taxa Taxa
</button> </button>
<button <button
type="button" type="button"
@@ -303,7 +309,7 @@ export function App() {
aria-current={view === 'flags' ? 'page' : undefined} aria-current={view === 'flags' ? 'page' : undefined}
onClick={() => setView('flags')} onClick={() => setView('flags')}
> >
Plant Flags Flags
</button> </button>
</div> </div>
</nav> </nav>
@@ -365,34 +371,41 @@ export function App() {
error={error} error={error}
form={bulkScheduleForm} form={bulkScheduleForm}
groups={plantGroups} groups={plantGroups}
editingScheduleId={editingScheduleId}
isLoading={isLoading} isLoading={isLoading}
isSaving={isSaving} isSaving={isSaving}
plants={plants} plants={plants}
schedules={plantCareSchedules}
onCancel={cancelEditingSchedule}
onDelete={removeSchedule}
onEdit={startEditingSchedule}
onFieldChange={updateBulkScheduleForm} onFieldChange={updateBulkScheduleForm}
onRemove={() => void removeBulkSchedule()} onNew={startNewSchedule}
onSave={() => void saveBulkSchedule()} onSave={() => void saveBulkSchedule()}
/> />
) : view === 'care' ? (
<CareView
activities={careActivities}
careTasks={careTasks}
error={error}
isLoading={isLoading}
isSaving={isSaving}
plants={plants}
onDismissCare={(payload) => void dismissCare(payload)}
onLogCare={(payload, requireDueSchedule) => void logCare(payload, requireDueSchedule)}
/>
) : view === 'taxa' ? ( ) : view === 'taxa' ? (
<TaxaView <TaxaView
activeTaxonName={activeTaxon?.name}
error={error} error={error}
form={taxonForm}
hasSearched={hasSearchedPlantInfo} hasSearched={hasSearchedPlantInfo}
isEditorOpen={isTaxonEditorOpen}
isLoading={isLoading} isLoading={isLoading}
isSaving={isSaving} isSaving={isSaving}
selectedTaxon={selectedTaxon} selectedTaxon={selectedTaxon}
taxa={plantTaxa} taxa={plantTaxa}
onCancel={cancelEditingTaxon}
onCloseDetail={() => setSelectedTaxonId(null)} onCloseDetail={() => setSelectedTaxonId(null)}
onDelete={(taxon) => void removeTaxon(taxon)} onDelete={(taxon) => void removeTaxon(taxon)}
onEdit={startEditingTaxon}
onFieldChange={updateTaxonForm}
onImportResult={(result) => void importTaxonFromPlantInfo(result)} onImportResult={(result) => void importTaxonFromPlantInfo(result)}
onNew={startAddingTaxon}
onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)} onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)}
onPrefillResult={startAddingTaxonFromPlantInfo}
onSave={() => void saveTaxon()}
onSearch={(query) => void searchTaxonInfo(query)} onSearch={(query) => void searchTaxonInfo(query)}
onSearchQueryChange={setPlantInfoQuery} onSearchQueryChange={setPlantInfoQuery}
plantInfoResults={plantInfoResults} plantInfoResults={plantInfoResults}
@@ -537,12 +550,10 @@ export function App() {
<HomeView <HomeView
careTasks={careTasks} careTasks={careTasks}
error={error} error={error}
groups={plantGroups}
isLoading={isLoading} isLoading={isLoading}
plants={plants} plants={plants}
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)} onOpenCare={() => setView('care')}
onCompleteTask={(task) => void completeTask(task)} onOpenPlants={() => setView('plant-management')}
onOpenPlant={openPlantDetail}
/> />
)} )}
</main> </main>
+31 -20
View File
@@ -9,9 +9,11 @@ import type {
BulkCompleteCareTasksPayload, BulkCompleteCareTasksPayload,
CareTask, CareTask,
BulkPlantCareSchedulePayload, BulkPlantCareSchedulePayload,
DismissCareTasksPayload,
Recipe, Recipe,
RecipePayload, RecipePayload,
Plant, Plant,
PlantCareScheduleRule,
PlantInfoSearchResult, PlantInfoSearchResult,
AssignPlantFlagPayload, AssignPlantFlagPayload,
PlantPayload, PlantPayload,
@@ -23,7 +25,6 @@ import type {
PlantLocation, PlantLocation,
PlantLocationPayload, PlantLocationPayload,
PlantTaxon, PlantTaxon,
PlantTaxonPayload,
} from './domain'; } from './domain';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
@@ -157,13 +158,6 @@ export async function deletePlantGroup(id: number) {
}); });
} }
export async function createPlantTaxon(payload: PlantTaxonPayload) {
return request<PlantTaxon>('/api/plant-taxa', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function importPlantTaxon(payload: PlantInfoSearchResult) { export async function importPlantTaxon(payload: PlantInfoSearchResult) {
return request<PlantTaxon>('/api/plant-taxa/import', { return request<PlantTaxon>('/api/plant-taxa/import', {
method: 'POST', method: 'POST',
@@ -171,13 +165,6 @@ export async function importPlantTaxon(payload: PlantInfoSearchResult) {
}); });
} }
export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) {
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deletePlantTaxon(id: number) { export async function deletePlantTaxon(id: number) {
return request<void>(`/api/plant-taxa/${id}`, { return request<void>(`/api/plant-taxa/${id}`, {
method: 'DELETE', method: 'DELETE',
@@ -347,23 +334,47 @@ export async function getCareTasks() {
return request<CareTask[]>('/api/care-tasks/upcoming'); return request<CareTask[]>('/api/care-tasks/upcoming');
} }
export async function savePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) { export async function getPlantCareSchedules() {
return request<{ updated: number }>('/api/plant-care-schedules/bulk', { return request<PlantCareScheduleRule[]>('/api/plant-care-schedules');
}
export async function createPlantCareSchedule(payload: BulkPlantCareSchedulePayload) {
return request<PlantCareScheduleRule>('/api/plant-care-schedules', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function removePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) { export async function updatePlantCareSchedule(id: number, payload: BulkPlantCareSchedulePayload) {
return request<{ removed: number }>('/api/plant-care-schedules/bulk-remove', { return request<PlantCareScheduleRule>(`/api/plant-care-schedules/${id}`, {
method: 'POST', method: 'PUT',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function deletePlantCareSchedule(id: number) {
return request<void>(`/api/plant-care-schedules/${id}`, {
method: 'DELETE',
});
}
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) { export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
return request<{ completed: number }>('/api/care-tasks/complete-bulk', { return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function dismissCareTasksBulk(payload: DismissCareTasksPayload) {
return request<{ dismissed: number }>('/api/care-tasks/dismiss-bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function createActionLogsBulk(payload: BulkCompleteCareTasksPayload) {
return request<{ completed: number }>('/api/action-logs/bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionResource, CareAction, CareActivity } from '../domain'; import type { ActionResource, CareAction, CareActivity, CareActivityRecipeComponent } from '../domain';
import type { ActivityFormState } from '../form-state'; import type { ActivityFormState } from '../form-state';
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui'; import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
@@ -147,14 +147,32 @@ export function ActivitiesView({
<h4>{activityAction.name}</h4> <h4>{activityAction.name}</h4>
<p>{activityAction.description ?? 'No description'}</p> <p>{activityAction.description ?? 'No description'}</p>
{activityAction.resources.length > 0 ? ( {activityAction.resources.length > 0 ? (
<p> <div className="activity-resource-detail-list">
{activityAction.resources.map((resource) => { {activityAction.resources.map((resource) => (
const amount = resource.quantity === null <div className="activity-resource-detail" key={resource.actionResourceId}>
? '' <p>{formatActivityResource(resource)}</p>
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`; {resource.producedByRecipe ? (
return `${resource.name}${amount}`; <div className="recipe-procedure">
}).join(', ')} <h5>{resource.producedByRecipe.name}</h5>
</p> {resource.producedByRecipe.components.length > 0 ? (
<ol>
{resource.producedByRecipe.components.map((component) => (
<li key={component.actionResourceId}>
{formatRecipeComponent(component)}
</li>
))}
</ol>
) : (
<p>No recipe components configured.</p>
)}
{resource.producedByRecipe.notes ? (
<p>{resource.producedByRecipe.notes}</p>
) : null}
</div>
) : null}
</div>
))}
</div>
) : ( ) : (
<p>No resources</p> <p>No resources</p>
)} )}
@@ -406,3 +424,18 @@ function formatActivitySummary(activity: CareActivity) {
}) })
.join(' | '); .join(' | ');
} }
function formatActivityResource(resource: CareActivity['actions'][number]['resources'][number]) {
const amount = resource.quantity === null
? ''
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
return `${resource.name}${amount}`;
}
function formatRecipeComponent(component: CareActivityRecipeComponent) {
const amount = component.quantity === null
? ''
: ` (${component.quantity}${component.unit ? ` ${component.unit}` : ''})`;
const notes = component.notes ? ` - ${component.notes}` : '';
return `${component.name}${amount}${notes}`;
}
@@ -0,0 +1,376 @@
import { useMemo, useState } from 'react';
import type { BulkCompleteCareTasksPayload, CareActivity, CareActivityActionResource, CareActivityRecipeComponent, CareTask, DismissCareTasksPayload, Plant } from '../domain';
import { SummaryStrip } from './Ui';
type CareMode = 'due' | 'upcoming' | 'adHoc';
type CareViewProps = {
activities: CareActivity[];
careTasks: CareTask[];
error: string | null;
isLoading: boolean;
isSaving: boolean;
plants: Plant[];
onDismissCare: (payload: DismissCareTasksPayload) => void;
onLogCare: (payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) => void;
};
const modeOptions: { value: CareMode; label: string }[] = [
{ value: 'due', label: 'Due' },
{ value: 'upcoming', label: 'Upcoming' },
{ value: 'adHoc', label: 'Ad Hoc' },
];
export function CareView({
activities,
careTasks,
error,
isLoading,
isSaving,
plants,
onDismissCare,
onLogCare,
}: CareViewProps) {
const [mode, setMode] = useState<CareMode>('due');
const [activityId, setActivityId] = useState('');
const [selectedPlantIds, setSelectedPlantIds] = useState<string[]>([]);
const [plantQuery, setPlantQuery] = useState('');
const [performedOn, setPerformedOn] = useState(() => new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState('');
const [resourceEdits, setResourceEdits] = useState<Record<number, { quantity: string; unit: string }>>({});
const modeTasks = careTasks.filter((task) => mode === 'due' ? task.status === 'due' : task.status === 'soon');
const activityOptions = mode === 'adHoc'
? activities
: activities.filter((activity) => modeTasks.some((task) => task.careActivityId === activity.id));
const selectedActivity = activities.find((activity) => String(activity.id) === activityId)
?? activityOptions[0];
const selectedActivityId = selectedActivity?.id ?? 0;
const taskPlants = modeTasks.filter((task) => task.careActivityId === selectedActivityId);
const visiblePlants = useMemo(
() => filterPlants(mode === 'adHoc' ? plants : plantsForTasks(taskPlants, plants), plantQuery),
[mode, plants, plantQuery, taskPlants],
);
const selectedPlantIdSet = new Set(selectedPlantIds);
const activityResources = selectedActivity ? getActivityResources(selectedActivity) : [];
function togglePlant(plantId: number, checked: boolean) {
const value = String(plantId);
setSelectedPlantIds((current) => checked
? [...new Set([...current, value])]
: current.filter((id) => id !== value));
}
function changeMode(nextMode: CareMode) {
setMode(nextMode);
setActivityId('');
setSelectedPlantIds([]);
setPlantQuery('');
}
function changeActivity(nextActivityId: string) {
setActivityId(nextActivityId);
setSelectedPlantIds([]);
setResourceEdits({});
}
function submit() {
if (!selectedActivity) {
return;
}
onLogCare({
careActivityId: selectedActivity.id,
plantIds: selectedPlantIds.map((id) => Number(id)),
performedOn,
notes: notes.trim() || null,
resources: activityResources.map((resource) => ({
actionResourceId: resource.actionResourceId,
quantity: normalizeQuantity(resourceEdits[resource.actionResourceId]?.quantity, resource.quantity),
unit: resourceEdits[resource.actionResourceId]?.unit.trim() || resource.unit,
})),
}, mode === 'due');
setSelectedPlantIds([]);
setNotes('');
}
function dismiss() {
if (!selectedActivity) {
return;
}
onDismissCare({
careActivityId: selectedActivity.id,
plantIds: selectedPlantIds.map((id) => Number(id)),
dismissedOn: performedOn,
notes: notes.trim() || null,
});
setSelectedPlantIds([]);
setNotes('');
}
return (
<>
<SummaryStrip ariaLabel="Care summary">
<p>{error ?? 'Choose care to perform, review the procedure, then log the work.'}</p>
</SummaryStrip>
<section className="work-panel" aria-labelledby="care-workflow-heading">
<div className="section-heading">
<div>
<h2 id="care-workflow-heading">Care</h2>
</div>
<span className="schedule-count">{selectedPlantIds.length} selected</span>
</div>
<div className="care-mode-switch" role="tablist" aria-label="Care mode">
{modeOptions.map((option) => (
<button
key={option.value}
type="button"
role="tab"
aria-selected={mode === option.value}
disabled={isSaving}
onClick={() => changeMode(option.value)}
>
{option.label}
</button>
))}
</div>
<div className="plant-form">
<label>
Activity
<select
disabled={isSaving || activityOptions.length === 0}
value={selectedActivity ? String(selectedActivity.id) : ''}
onChange={(event) => changeActivity(event.target.value)}
>
{activityOptions.length === 0 ? (
<option value="">No activities available</option>
) : null}
{activityOptions.map((activity) => (
<option key={activity.id} value={activity.id}>
{activity.name}
</option>
))}
</select>
</label>
<label>
Date
<input
disabled={isSaving}
type="date"
value={performedOn}
onChange={(event) => setPerformedOn(event.target.value)}
/>
</label>
<label>
Search plants
<input
disabled={isSaving}
type="search"
value={plantQuery}
onChange={(event) => setPlantQuery(event.target.value)}
/>
</label>
</div>
<div className="care-layout">
<div className="work-panel care-subpanel" aria-labelledby="care-plants-heading">
<div className="section-heading">
<h2 id="care-plants-heading">Plants</h2>
<button
className="text-button"
type="button"
disabled={isSaving || visiblePlants.length === 0}
onClick={() => {
const allVisibleSelected = visiblePlants.every((plant) => selectedPlantIdSet.has(String(plant.id)));
setSelectedPlantIds(allVisibleSelected
? selectedPlantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
: [...new Set([...selectedPlantIds, ...visiblePlants.map((plant) => String(plant.id))])]);
}}
>
Select visible
</button>
</div>
<div className="plant-list compact-plant-list">
{!isLoading && visiblePlants.length === 0 ? (
<p className="empty-state">{mode === 'adHoc' ? 'No plants match that search.' : 'No care tasks in this queue.'}</p>
) : null}
{visiblePlants.map((plant) => {
const task = taskPlants.find((item) => item.plantId === plant.id);
return (
<label className="plant-row check-row" key={plant.id}>
<span>
<input
checked={selectedPlantIdSet.has(String(plant.id))}
disabled={isSaving}
type="checkbox"
onChange={(event) => togglePlant(plant.id, event.target.checked)}
/>
<strong>{plant.nickname}</strong>
<small>{mode === 'adHoc' ? `${plant.taxon} - ${plant.location}` : task?.due ?? plant.nextCare}</small>
</span>
</label>
);
})}
</div>
</div>
<div className="work-panel care-subpanel" aria-labelledby="care-procedure-heading">
<div className="section-heading">
<h2 id="care-procedure-heading">Procedure</h2>
</div>
{!selectedActivity ? (
<p className="empty-state">Select an activity.</p>
) : (
<div className="detail-list">
{selectedActivity.actions.map((action) => (
<div className="detail-row" key={action.careActionId}>
<div>
<h4>{action.name}</h4>
<p>{action.description ?? 'No description'}</p>
{action.resources.length === 0 ? (
<p>No resources configured.</p>
) : action.resources.map((resource) => (
<div className="activity-resource-detail" key={resource.actionResourceId}>
<p>{formatResource(resource)}</p>
<div className="care-resource-edit">
<input
aria-label={`${resource.name} quantity`}
disabled={isSaving}
type="number"
min="0"
value={resourceEdits[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity)}
onChange={(event) => setResourceEdits((current) => ({
...current,
[resource.actionResourceId]: {
quantity: event.target.value,
unit: current[resource.actionResourceId]?.unit ?? resource.unit ?? '',
},
}))}
/>
<input
aria-label={`${resource.name} unit`}
disabled={isSaving}
value={resourceEdits[resource.actionResourceId]?.unit ?? resource.unit ?? ''}
onChange={(event) => setResourceEdits((current) => ({
...current,
[resource.actionResourceId]: {
quantity: current[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity),
unit: event.target.value,
},
}))}
/>
</div>
{resource.producedByRecipe ? (
<div className="recipe-procedure">
<h5>{resource.producedByRecipe.name}</h5>
<ol>
{resource.producedByRecipe.components.map((component) => (
<li key={component.actionResourceId}>{formatRecipeComponent(component)}</li>
))}
</ol>
{resource.producedByRecipe.notes ? <p>{resource.producedByRecipe.notes}</p> : null}
</div>
) : null}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
<label className="care-notes">
Notes
<textarea
disabled={isSaving}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</label>
<div className="form-actions">
<button
className="primary-action"
type="button"
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
onClick={submit}
>
{isSaving ? 'Saving' : 'Log'}
</button>
{mode === 'due' ? (
<button
className="text-button"
type="button"
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
onClick={dismiss}
>
Dismiss
</button>
) : null}
</div>
</section>
</>
);
}
function plantsForTasks(tasks: CareTask[], plants: Plant[]) {
const taskPlantIds = new Set(tasks.map((task) => task.plantId));
return plants.filter((plant) => taskPlantIds.has(plant.id));
}
function filterPlants(plants: Plant[], query: string) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return plants;
}
return plants.filter((plant) => `${plant.nickname} ${plant.taxon} ${plant.location}`.toLowerCase().includes(normalizedQuery));
}
function getActivityResources(activity: CareActivity) {
const resources = new Map<number, CareActivityActionResource>();
for (const action of activity.actions) {
for (const resource of action.resources) {
if (!resources.has(resource.actionResourceId)) {
resources.set(resource.actionResourceId, resource);
}
}
}
return [...resources.values()];
}
function normalizeQuantity(value: string | undefined, fallback: number | null) {
if (value === undefined || value.trim() === '') {
return fallback;
}
return Number(value);
}
function formatResource(resource: CareActivityActionResource) {
const quantity = resource.quantity === null ? '' : `${resource.quantity} `;
const unit = resource.unit ? `${resource.unit} ` : '';
return `${quantity}${unit}${resource.name}`.trim();
}
function formatRecipeComponent(component: CareActivityRecipeComponent) {
const quantity = component.quantity === null ? '' : `${component.quantity}`;
const unit = component.unit ?? '';
return `${component.name}: ${quantity}${unit ? ` ${unit}` : ''}`.trim();
}
function formatNumber(value: number | null) {
return value === null ? '' : String(value);
}
+79 -287
View File
@@ -1,332 +1,124 @@
import { useMemo, useState } from 'react'; import type { ActionLog, CareTask, Plant } from '../domain';
import type { CareTask, Plant, PlantGroup } from '../domain';
import { PlantCard } from './PlantCard';
import { SummaryStrip } from './Ui'; import { SummaryStrip } from './Ui';
type HomeViewProps = { type HomeViewProps = {
careTasks: CareTask[]; careTasks: CareTask[];
error: string | null; error: string | null;
groups: PlantGroup[];
isLoading: boolean; isLoading: boolean;
plants: Plant[]; plants: Plant[];
onCompleteBulkTasks: (tasks: CareTask[]) => void; onOpenCare: () => void;
onCompleteTask: (task: CareTask) => void; onOpenPlants: () => void;
onOpenPlant: (plant: Plant) => void;
}; };
export function HomeView({ export function HomeView({
careTasks, careTasks,
error, error,
groups,
isLoading, isLoading,
plants, plants,
onCompleteBulkTasks, onOpenCare,
onCompleteTask, onOpenPlants,
onOpenPlant,
}: HomeViewProps) { }: HomeViewProps) {
const dueTaskGroups = groupDueTasks(careTasks); const dueCount = careTasks.filter((task) => task.status === 'due').length;
const weekDays = useMemo(() => getWeekDays(), []); const upcomingCount = careTasks.filter((task) => task.status === 'soon').length;
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date())); const activeFlagCount = plants.reduce(
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0]; (count, plant) => count + plant.flags.filter((flag) => flag.resolvedOn === null).length,
const selectedTasks = getTasksForDate(careTasks, selectedDate); 0,
const groupDueTaskGroups = getGroupDueTaskGroups(careTasks, groups); );
const recentLogs = getRecentLogs(plants);
return ( return (
<> <>
<SummaryStrip ariaLabel="Dashboard summary"> <SummaryStrip ariaLabel="Dashboard summary">
<p>{error ?? 'Start with the plants that need attention now.'}</p> <p>{error ?? 'Quick status for care and collection health.'}</p>
</SummaryStrip> </SummaryStrip>
<section className="work-panel" aria-labelledby="calendar-heading"> <div className="dashboard-panel-grid">
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-care-heading">
<div className="section-heading">
<h2 id="dashboard-care-heading">Care</h2>
</div>
<div className="dashboard-stat-grid">
<DashboardStat label="Due" value={dueCount} />
<DashboardStat label="Upcoming" value={upcomingCount} />
</div>
<div className="form-actions">
<button className="primary-action" type="button" onClick={onOpenCare}>
Open Care
</button>
</div>
</section>
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-plants-heading">
<div className="section-heading">
<h2 id="dashboard-plants-heading">Plants</h2>
</div>
<div className="dashboard-stat-grid">
<DashboardStat label="Total" value={plants.length} />
<DashboardStat label="Flagged" value={activeFlagCount} />
</div>
<div className="form-actions">
<button className="primary-action" type="button" onClick={onOpenPlants}>
Open Plant Management
</button>
</div>
</section>
</div>
<section className="work-panel" aria-labelledby="recent-care-heading">
<div className="section-heading"> <div className="section-heading">
<h2 id="calendar-heading">Care Calendar</h2> <h2 id="recent-care-heading">Recent Care</h2>
<span className="schedule-count">{selectedTasks.length} selected</span>
</div> </div>
<div className="week-strip" role="tablist" aria-label="Care tasks by day"> <div className="detail-list">
{weekDays.map((day) => { {!isLoading && recentLogs.length === 0 ? (
const dayTasks = getTasksForDate(careTasks, day.dateKey); <p className="empty-state">No care has been logged yet.</p>
const hasDueTasks = dayTasks.some((task) => task.status === 'due');
return (
<button
className="week-day"
type="button"
role="tab"
aria-selected={day.dateKey === selectedDate}
key={day.dateKey}
onClick={() => setSelectedDate(day.dateKey)}
>
<span>{day.label}</span>
<strong>{day.dayNumber}</strong>
<small>{dayTasks.length}</small>
{hasDueTasks ? <span className="week-day-alert" aria-hidden="true" /> : null}
</button>
);
})}
</div>
<div className="task-list">
<p className="task-list-label">{selectedDay?.heading ?? 'Selected day'}</p>
{!isLoading && selectedTasks.length === 0 ? (
<p className="empty-state">No care scheduled for this day.</p>
) : null} ) : null}
{selectedTasks.map((task) => ( {recentLogs.map((log) => (
<article className="task-row" key={`calendar-${task.id}`}> <article className="detail-row" key={log.id}>
<span className={`status-dot ${task.status}`} />
<div> <div>
<h3>{task.action}</h3> <h4>{log.action}</h4>
<p>{task.plantName} - {task.due}</p> <p>{log.plantName} - {formatDate(log.performedOn)}</p>
{log.notes ? <p>{log.notes}</p> : null}
</div> </div>
<button
className="small-action"
type="button"
disabled={task.status !== 'due'}
onClick={() => onCompleteTask(task)}
>
Log
</button>
</article> </article>
))} ))}
</div> </div>
</section> </section>
<section className="work-panel" aria-labelledby="care-heading">
<div className="section-heading">
<h2 id="care-heading">Due Care</h2>
<button className="text-button" type="button">View all</button>
</div>
<div className="task-list">
{!isLoading && careTasks.length === 0 ? (
<p className="empty-state">No care tasks yet.</p>
) : null}
{dueTaskGroups.length > 0 ? (
<>
<p className="task-list-label">Bulk actions</p>
{dueTaskGroups.map((group) => (
<article className="task-row task-row-group" key={group.careActivityId}>
<span className="status-dot due" />
<div>
<h3>{group.action}</h3>
<p>
{group.tasks.length} due - {formatPlantNames(group.tasks)}
</p>
</div>
<button
className="small-action"
type="button"
onClick={() => onCompleteBulkTasks(group.tasks)}
>
Log all due
</button>
</article>
))}
</>
) : null}
{groupDueTaskGroups.length > 0 ? (
<>
<p className="task-list-label">Group actions</p>
{groupDueTaskGroups.map((group) => (
<article className="task-row task-row-group" key={`${group.groupId}-${group.careActivityId}`}>
<span className="status-dot due" />
<div>
<h3>{group.groupName} / {group.action}</h3>
<p>
{group.tasks.length} due - {formatPlantNames(group.tasks)}
</p>
</div>
<button
className="small-action"
type="button"
onClick={() => onCompleteBulkTasks(group.tasks)}
>
Log group
</button>
</article>
))}
</>
) : null}
{careTasks.length > 0 ? (
<>
<p className="task-list-label">Individual tasks</p>
{careTasks.map((task) => (
<article className="task-row" key={task.id}>
<span className={`status-dot ${task.status}`} />
<div>
<h3>{task.action}</h3>
<p>{task.plantName} - {task.due}</p>
</div>
<button
className="small-action"
type="button"
onClick={() => onCompleteTask(task)}
>
Log
</button>
</article>
))}
</>
) : null}
</div>
</section>
<section className="work-panel" aria-labelledby="plants-heading">
<div className="section-heading">
<h2 id="plants-heading">My Plants</h2>
</div>
<div className="plant-grid">
{!isLoading && plants.length === 0 ? (
<p className="empty-state">No plants yet.</p>
) : null}
{plants.map((plant) => (
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
))}
</div>
</section>
</> </>
); );
} }
function groupDueTasks(tasks: CareTask[]) { function DashboardStat({ label, value }: { label: string; value: number }) {
const groups = new Map<number, { careActivityId: number; action: string; tasks: CareTask[] }>(); return (
for (const task of tasks) { <div className="dashboard-stat">
if (task.status !== 'due') { <strong>{value}</strong>
continue; <span>{label}</span>
} </div>
);
}
const group = groups.get(task.careActivityId); function getRecentLogs(plants: Plant[]) {
if (group) { return plants
group.tasks.push(task); .flatMap((plant) => plant.actionLogs.map((log) => ({ ...log, plantName: log.plantName || plant.nickname })))
continue; .sort(compareLogs)
} .slice(0, 5);
}
groups.set(task.careActivityId, { function compareLogs(left: ActionLog, right: ActionLog) {
careActivityId: task.careActivityId, const dateComparison = right.performedOn.localeCompare(left.performedOn);
action: task.action, if (dateComparison !== 0) {
tasks: [task], return dateComparison;
});
} }
return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action)); return right.id - left.id;
} }
function getGroupDueTaskGroups(tasks: CareTask[], groups: PlantGroup[]) { function formatDate(value: string) {
const dueTasks = tasks.filter((task) => task.status === 'due'); const [year, month, day] = value.split('-');
if (!year || !month || !day) {
return groups.flatMap((group) => { return value;
const groupPlantIds = new Set(group.plants.map((plant) => plant.id));
return groupDueTasks(dueTasks.filter((task) => groupPlantIds.has(task.plantId)))
.map((taskGroup) => ({
...taskGroup,
groupId: group.id,
groupName: group.name,
}));
});
}
function formatPlantNames(tasks: CareTask[]) {
const names = tasks.map((task) => task.plantName);
if (names.length <= 3) {
return names.join(', ');
} }
return `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`; return `${month}/${day}/${year}`;
}
function getWeekDays() {
const today = new Date();
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() + index);
const dateKey = getDateKey(date);
return {
dateKey,
dayNumber: date.getDate().toString(),
heading: index === 0
? 'Today'
: date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' }),
label: index === 0
? 'Today'
: date.toLocaleDateString(undefined, { weekday: 'short' }),
};
});
}
function getTasksForDate(tasks: CareTask[], dateKey: string) {
const todayKey = getDateKey(new Date());
return tasks
.filter((task) => {
const taskDateKey = getTaskDateKey(task);
if (!taskDateKey) {
return false;
}
if (dateKey === todayKey) {
return taskDateKey <= todayKey;
}
return taskDateKey === dateKey;
})
.sort(compareCareTasks);
}
function compareCareTasks(left: CareTask, right: CareTask) {
const dueComparison = (getTaskDateKey(left) ?? '').localeCompare(getTaskDateKey(right) ?? '');
if (dueComparison !== 0) {
return dueComparison;
}
const plantComparison = left.plantName.localeCompare(right.plantName);
if (plantComparison !== 0) {
return plantComparison;
}
return left.action.localeCompare(right.action);
}
function getTaskDateKey(task: CareTask) {
if (task.dueDate) {
return task.dueDate;
}
const today = new Date();
if (task.due === 'Today' || task.due === 'Yesterday' || task.due.endsWith(' days ago')) {
return getDateKey(today);
}
if (task.due === 'Tomorrow') {
return getOffsetDateKey(today, 1);
}
const relativeMatch = /^In (\d+) days$/.exec(task.due);
if (relativeMatch) {
return getOffsetDateKey(today, Number(relativeMatch[1]));
}
return null;
}
function getOffsetDateKey(date: Date, offsetDays: number) {
const nextDate = new Date(date);
nextDate.setDate(date.getDate() + offsetDays);
return getDateKey(nextDate);
}
function getDateKey(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} }
@@ -28,7 +28,7 @@ type PlantManagementViewProps = {
onDeletePlant: (plant: Plant) => void; onDeletePlant: (plant: Plant) => void;
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void; onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
onNewPlant: () => void; onNewPlant: () => void;
onPlantFieldChange: (field: keyof PlantFormState, value: string) => void; onPlantFieldChange: (field: keyof PlantFormState, value: PlantFormState[keyof PlantFormState]) => void;
onRemoveFlag: (flag: PlantFlag) => void; onRemoveFlag: (flag: PlantFlag) => void;
onResolveFlag: (flag: PlantFlag) => void; onResolveFlag: (flag: PlantFlag) => void;
onSavePlant: () => void; onSavePlant: () => void;
@@ -1,18 +1,23 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { CareActivity, Plant, PlantGroup } from '../domain'; import type { CareActivity, Plant, PlantCareScheduleRule, PlantGroup } from '../domain';
import type { BulkScheduleFormState } from '../form-state'; import type { BulkScheduleFormState } from '../form-state';
import { SummaryStrip } from './Ui'; import { SummaryStrip } from './Ui';
type SchedulesViewProps = { type SchedulesViewProps = {
activities: CareActivity[]; activities: CareActivity[];
editingScheduleId: number | null;
error: string | null; error: string | null;
form: BulkScheduleFormState; form: BulkScheduleFormState;
groups: PlantGroup[]; groups: PlantGroup[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
plants: Plant[]; plants: Plant[];
schedules: PlantCareScheduleRule[];
onCancel: () => void;
onDelete: (schedule: PlantCareScheduleRule) => void;
onEdit: (schedule: PlantCareScheduleRule) => void;
onFieldChange: (field: keyof BulkScheduleFormState, value: string | string[]) => void; onFieldChange: (field: keyof BulkScheduleFormState, value: string | string[]) => void;
onRemove: () => void; onNew: () => void;
onSave: () => void; onSave: () => void;
}; };
@@ -37,14 +42,19 @@ const weekdayOptions = [
export function SchedulesView({ export function SchedulesView({
activities, activities,
editingScheduleId,
error, error,
form, form,
groups, groups,
isLoading, isLoading,
isSaving, isSaving,
plants, plants,
schedules,
onCancel,
onDelete,
onEdit,
onFieldChange, onFieldChange,
onRemove, onNew,
onSave, onSave,
}: SchedulesViewProps) { }: SchedulesViewProps) {
const [plantQuery, setPlantQuery] = useState(''); const [plantQuery, setPlantQuery] = useState('');
@@ -67,12 +77,15 @@ export function SchedulesView({
<> <>
<SummaryStrip ariaLabel="Schedules summary"> <SummaryStrip ariaLabel="Schedules summary">
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p> <p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
<button className="primary-action" type="button" disabled={isSaving} onClick={onNew}>
New
</button>
</SummaryStrip> </SummaryStrip>
<section className="work-panel" aria-labelledby="bulk-schedule-heading"> <section className="work-panel" aria-labelledby="bulk-schedule-heading">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2 id="bulk-schedule-heading">Apply activity interval</h2> <h2 id="bulk-schedule-heading">{editingScheduleId === null ? 'New Schedule' : 'Edit Schedule'}</h2>
</div> </div>
<div className="schedule-count"> <div className="schedule-count">
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span> <span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
@@ -104,27 +117,6 @@ export function SchedulesView({
onChange={(event) => onFieldChange('scheduledFor', event.target.value)} onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
/> />
</label> </label>
<label>
Target group
<select
disabled={isSaving}
value={selectedGroupId}
onChange={(event) => {
const groupId = event.target.value;
const group = groups.find((item) => String(item.id) === groupId);
setSelectedGroupId(groupId);
setPlantQuery('');
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
}}
>
<option value="">Individual plants</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.plants.length})
</option>
))}
</select>
</label>
</div> </div>
<fieldset className="schedule-options"> <fieldset className="schedule-options">
@@ -195,6 +187,17 @@ export function SchedulesView({
{form.recurrenceMode !== 'none' ? ( {form.recurrenceMode !== 'none' ? (
<fieldset className="schedule-end-options"> <fieldset className="schedule-end-options">
<legend>Ends</legend> <legend>Ends</legend>
<label className="check-option">
<input
checked={form.endsMode === 'never'}
disabled={isSaving}
type="radio"
name="endsMode"
value="never"
onChange={(event) => onFieldChange('endsMode', event.target.value)}
/>
Never
</label>
<label className="check-option"> <label className="check-option">
<input <input
checked={form.endsMode === 'on'} checked={form.endsMode === 'on'}
@@ -278,16 +281,39 @@ export function SchedulesView({
</div> </div>
</div> </div>
<label className="compact-search"> <div className="plant-form">
<span className="sr-only">Search target plants</span> <label>
<input Target group
disabled={isSaving} <select
type="search" disabled={isSaving}
value={plantQuery} value={selectedGroupId}
placeholder="Search plants" onChange={(event) => {
onChange={(event) => setPlantQuery(event.target.value)} const groupId = event.target.value;
/> const group = groups.find((item) => String(item.id) === groupId);
</label> setSelectedGroupId(groupId);
setPlantQuery('');
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
}}
>
<option value="">Individual plants</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.plants.length})
</option>
))}
</select>
</label>
<label>
Search plants
<input
disabled={isSaving}
type="search"
value={plantQuery}
placeholder="Search plants"
onChange={(event) => setPlantQuery(event.target.value)}
/>
</label>
</div>
<div className="plant-list compact-plant-list"> <div className="plant-list compact-plant-list">
{!isLoading && plants.length === 0 ? ( {!isLoading && plants.length === 0 ? (
@@ -332,22 +358,64 @@ export function SchedulesView({
setSelectedGroupId(''); setSelectedGroupId('');
}} }}
> >
{isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`} {isSaving ? 'Saving' : 'Save'}
</button> </button>
<button <button
className="text-button danger" className="text-button"
type="button" type="button"
disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId} disabled={isSaving}
onClick={() => { onClick={() => {
onRemove(); onCancel();
setSelectedGroupId(''); setSelectedGroupId('');
}} }}
> >
Remove from {form.plantIds.length} plants Cancel
</button> </button>
</div> </div>
</section> </section>
<section className="work-panel" aria-labelledby="schedules-list-heading">
<div className="section-heading">
<div>
<h2 id="schedules-list-heading">Schedules</h2>
</div>
</div>
<div className="detail-list">
{!isLoading && schedules.length === 0 ? (
<p className="empty-state">No schedules yet.</p>
) : null}
{schedules.map((schedule) => (
<article className="detail-row schedule-detail-row" key={schedule.id}>
<div>
<h4>{schedule.action}</h4>
<p>{formatRecurrence(schedule)}</p>
<p>{formatSchedulePlants(schedule)}</p>
</div>
<div className="row-actions">
<button
className="text-button"
type="button"
disabled={isSaving}
onClick={() => onEdit(schedule)}
>
Edit
</button>
<button
className="text-button danger"
type="button"
disabled={isSaving}
onClick={() => onDelete(schedule)}
>
Delete
</button>
</div>
</article>
))}
</div>
</section>
<section className="work-panel" aria-labelledby="current-schedules-heading"> <section className="work-panel" aria-labelledby="current-schedules-heading">
<div className="section-heading"> <div className="section-heading">
<div> <div>
@@ -398,6 +466,19 @@ function formatPlantScheduleSummary(plant: Plant, selectedActivity?: CareActivit
return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.nextCare}`; return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.nextCare}`;
} }
function formatSchedulePlants(schedule: PlantCareScheduleRule) {
if (schedule.plants.length === 0) {
return 'No plants assigned';
}
const names = schedule.plants.map((plant) => plant.nickname);
if (names.length <= 4) {
return names.join(', ');
}
return `${names.slice(0, 4).join(', ')} + ${names.length - 4} more`;
}
function filterPlants(plants: Plant[], query: string) { function filterPlants(plants: Plant[], query: string) {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) { if (!normalizedQuery) {
+12 -119
View File
@@ -1,15 +1,11 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { PlantInfoSearchResult, PlantTaxon } from '../domain'; import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
import type { TaxonFormState } from '../form-state';
import { formatTaxon } from '../form-state'; import { formatTaxon } from '../form-state';
import { CatalogFilterSection, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui'; import { CatalogFilterSection, EntityList, SummaryStrip } from './Ui';
type TaxaViewProps = { type TaxaViewProps = {
activeTaxonName?: string;
error: string | null; error: string | null;
form: TaxonFormState;
hasSearched: boolean; hasSearched: boolean;
isEditorOpen: boolean;
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
isSearching: boolean; isSearching: boolean;
@@ -17,16 +13,10 @@ type TaxaViewProps = {
searchQuery: string; searchQuery: string;
selectedTaxon?: PlantTaxon; selectedTaxon?: PlantTaxon;
taxa: PlantTaxon[]; taxa: PlantTaxon[];
onCancel: () => void;
onCloseDetail: () => void; onCloseDetail: () => void;
onDelete: (taxon: PlantTaxon) => void; onDelete: (taxon: PlantTaxon) => void;
onEdit: (taxon: PlantTaxon) => void;
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
onImportResult: (result: PlantInfoSearchResult) => void; onImportResult: (result: PlantInfoSearchResult) => void;
onNew: () => void;
onOpenDetail: (taxon: PlantTaxon) => void; onOpenDetail: (taxon: PlantTaxon) => void;
onPrefillResult: (result: PlantInfoSearchResult) => void;
onSave: () => void;
onSearch: (query?: string) => void; onSearch: (query?: string) => void;
onSearchQueryChange: (value: string) => void; onSearchQueryChange: (value: string) => void;
}; };
@@ -40,11 +30,8 @@ function getPlantInfoSubtitle(result: PlantInfoSearchResult) {
} }
export function TaxaView({ export function TaxaView({
activeTaxonName,
error, error,
form,
hasSearched, hasSearched,
isEditorOpen,
isLoading, isLoading,
isSaving, isSaving,
isSearching, isSearching,
@@ -52,16 +39,10 @@ export function TaxaView({
searchQuery, searchQuery,
selectedTaxon, selectedTaxon,
taxa, taxa,
onCancel,
onCloseDetail, onCloseDetail,
onDelete, onDelete,
onEdit,
onFieldChange,
onImportResult, onImportResult,
onNew,
onOpenDetail, onOpenDetail,
onPrefillResult,
onSave,
onSearch, onSearch,
onSearchQueryChange, onSearchQueryChange,
}: TaxaViewProps) { }: TaxaViewProps) {
@@ -83,9 +64,8 @@ export function TaxaView({
<> <>
<SummaryStrip <SummaryStrip
ariaLabel="Taxa summary" ariaLabel="Taxa summary"
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
> >
<p>{error ?? 'Create and maintain the plant identities used by your collection.'}</p> <p>{error ?? 'Import GBIF-backed plant identities for use in your collection.'}</p>
</SummaryStrip> </SummaryStrip>
<CatalogFilterSection <CatalogFilterSection
@@ -101,14 +81,14 @@ export function TaxaView({
isLoading={isLoading} isLoading={isLoading}
items={filteredTaxa} items={filteredTaxa}
renderActions={(taxon) => ( renderActions={(taxon) => (
<RecordActions <>
deleteLabel={`Delete ${taxon.name}`} <button className="icon-button compact" type="button" aria-label={`View ${taxon.name}`} onClick={() => onOpenDetail(taxon)}>
editLabel={`Edit ${taxon.name}`} View
viewLabel={`View ${taxon.name}`} </button>
onDelete={() => onDelete(taxon)} <button className="icon-button compact danger" type="button" aria-label={`Delete ${taxon.name}`} onClick={() => onDelete(taxon)}>
onEdit={() => onEdit(taxon)} Delete
onView={() => onOpenDetail(taxon)} </button>
/> </>
)} )}
renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>} renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>}
/> />
@@ -116,7 +96,7 @@ export function TaxaView({
<section className="work-panel" aria-labelledby="taxa-search-heading"> <section className="work-panel" aria-labelledby="taxa-search-heading">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2 id="taxa-search-heading">Search plant info</h2> <h2 id="taxa-search-heading">Search GBIF</h2>
</div> </div>
</div> </div>
@@ -187,9 +167,6 @@ export function TaxaView({
</dl> </dl>
</div> </div>
<div className="row-actions"> <div className="row-actions">
<button className="small-action" type="button" onClick={() => onPrefillResult(result)}>
Prefill
</button>
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}> <button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
Import Import
</button> </button>
@@ -202,16 +179,13 @@ export function TaxaView({
) : null} ) : null}
</section> </section>
{selectedTaxon && !isEditorOpen ? ( {selectedTaxon ? (
<section className="work-panel" aria-labelledby="taxon-detail-heading"> <section className="work-panel" aria-labelledby="taxon-detail-heading">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2> <h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2>
</div> </div>
<div className="row-actions"> <div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedTaxon.name}`} onClick={() => onEdit(selectedTaxon)}>
Edit
</button>
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}> <button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
Close Close
</button> </button>
@@ -254,87 +228,6 @@ export function TaxaView({
</section> </section>
) : null} ) : null}
{isEditorOpen ? (
<section className="work-panel" aria-labelledby="taxon-editor-heading">
<div className="section-heading">
<div>
<h2 id="taxon-editor-heading">{activeTaxonName ?? 'New taxon'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancel}>
Close
</button>
</div>
<div className="plant-form">
<label>
Common name
<input
value={form.name}
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<label>
Genus
<input
value={form.genus}
onChange={(event) => onFieldChange('genus', event.target.value)}
/>
</label>
<label>
Species
<input
value={form.species}
onChange={(event) => onFieldChange('species', event.target.value)}
/>
</label>
<label>
Cultivar
<input
value={form.cultivar}
onChange={(event) => onFieldChange('cultivar', event.target.value)}
/>
</label>
<label>
Variety
<input
value={form.variety}
onChange={(event) => onFieldChange('variety', event.target.value)}
/>
</label>
<label>
Authority
<input
value={form.authority}
onChange={(event) => onFieldChange('authority', event.target.value)}
/>
</label>
<label>
Family
<input
value={form.family}
onChange={(event) => onFieldChange('family', event.target.value)}
/>
</label>
<label>
GBIF ID
<input
value={form.externalId}
onChange={(event) => onFieldChange('externalId', event.target.value)}
/>
</label>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
{isSaving ? 'Saving' : 'Save'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
</> </>
); );
} }
+48 -14
View File
@@ -36,9 +36,31 @@ export type PlantCareSchedule = {
status: CareStatus; status: CareStatus;
}; };
export type PlantCareScheduleAssignment = {
id: number;
nickname: string;
};
export type PlantCareScheduleRule = {
id: number;
careActivityId: number;
careActionId: number;
action: string;
everyDays: number;
scheduledFor: string | null;
recurrenceMode: ScheduleRecurrenceMode;
repeatEvery: number;
repeatUnit: ScheduleRepeatUnit;
repeatOnDays: string | null;
endsMode: ScheduleEndsMode;
endsOn: string | null;
endsAfterOccurrences: number | null;
plants: PlantCareScheduleAssignment[];
};
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year'; export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
export type ScheduleEndsMode = 'on' | 'after'; export type ScheduleEndsMode = 'never' | 'on' | 'after';
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent'; export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
export type PlantTaxon = { export type PlantTaxon = {
@@ -183,6 +205,24 @@ export type CareActivityActionResource = {
quantity: number | null; quantity: number | null;
unit: string | null; unit: string | null;
notes: string | null; notes: string | null;
producedByRecipe: CareActivityRecipe | null;
};
export type CareActivityRecipe = {
id: number;
name: string;
measurementMode: RecipeMeasurementMode;
components: CareActivityRecipeComponent[];
notes: string | null;
};
export type CareActivityRecipeComponent = {
actionResourceId: number;
name: string;
quantity: number | null;
unit: string | null;
notes: string | null;
sortOrder: number;
}; };
export type PlantFlagDefinition = { export type PlantFlagDefinition = {
@@ -255,19 +295,6 @@ export type BulkPlantCareSchedulePayload = {
endsAfterOccurrences: number | null; endsAfterOccurrences: number | null;
}; };
export type PlantTaxonPayload = {
name: string;
genus: string;
species: string;
cultivar: string | null;
variety: string | null;
authority: string | null;
family: string | null;
commonName: string | null;
externalSource: string | null;
externalId: string | null;
};
export type PlantLocationPayload = { export type PlantLocationPayload = {
name: string; name: string;
notes: string | null; notes: string | null;
@@ -341,6 +368,13 @@ export type BulkCompleteCareTasksPayload = {
resources: CareLogResourcePayload[]; resources: CareLogResourcePayload[];
}; };
export type DismissCareTasksPayload = {
careActivityId: number;
plantIds: number[];
notes: string | null;
dismissedOn: string | null;
};
export type CareLogResourcePayload = { export type CareLogResourcePayload = {
actionResourceId: number; actionResourceId: number;
quantity: number | null; quantity: number | null;
+19 -63
View File
@@ -9,7 +9,7 @@ import type {
Recipe, Recipe,
RecipePayload, RecipePayload,
Plant, Plant,
PlantInfoSearchResult, PlantCareScheduleRule,
AssignPlantFlagPayload, AssignPlantFlagPayload,
PlantPayload, PlantPayload,
PlantFlagDefinition, PlantFlagDefinition,
@@ -23,7 +23,6 @@ import type {
ScheduleRecurrenceMode, ScheduleRecurrenceMode,
ScheduleRepeatUnit, ScheduleRepeatUnit,
PlantTaxon, PlantTaxon,
PlantTaxonPayload,
} from './domain'; } from './domain';
export const emptyPlantForm = { export const emptyPlantForm = {
@@ -33,19 +32,6 @@ export const emptyPlantForm = {
locationId: '', locationId: '',
}; };
export const emptyTaxonForm = {
name: '',
genus: '',
species: '',
cultivar: '',
variety: '',
authority: '',
family: '',
commonName: '',
externalSource: '',
externalId: '',
};
export const emptyLocationForm = { export const emptyLocationForm = {
name: '', name: '',
notes: '', notes: '',
@@ -119,14 +105,13 @@ export const emptyBulkScheduleForm = {
repeatEvery: '1', repeatEvery: '1',
repeatUnit: 'week', repeatUnit: 'week',
repeatOnDays: [] as string[], repeatOnDays: [] as string[],
endsMode: 'after', endsMode: 'never',
endsOn: '', endsOn: '',
endsAfterOccurrences: '12', endsAfterOccurrences: '12',
plantIds: [] as string[], plantIds: [] as string[],
}; };
export type PlantFormState = typeof emptyPlantForm; export type PlantFormState = typeof emptyPlantForm;
export type TaxonFormState = typeof emptyTaxonForm;
export type LocationFormState = typeof emptyLocationForm; export type LocationFormState = typeof emptyLocationForm;
export type PlantGroupFormState = typeof emptyPlantGroupForm; export type PlantGroupFormState = typeof emptyPlantGroupForm;
export type ActionFormState = typeof emptyActionForm; export type ActionFormState = typeof emptyActionForm;
@@ -136,7 +121,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm; export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm; export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type BulkScheduleFormState = typeof emptyBulkScheduleForm; export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
export type View = 'home' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export'; export type View = 'home' | 'plant-management' | 'care' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
export function toPlantForm(plant: Plant): PlantFormState { export function toPlantForm(plant: Plant): PlantFormState {
return { return {
@@ -187,51 +172,6 @@ export function toPlantGroupPayload(form: PlantGroupFormState): PlantGroupPayloa
}; };
} }
export function toTaxonForm(taxon: PlantTaxon): TaxonFormState {
return {
name: taxon.name,
genus: taxon.genus,
species: taxon.species,
cultivar: taxon.cultivar ?? '',
variety: taxon.variety ?? '',
authority: taxon.authority ?? '',
family: taxon.family ?? '',
commonName: taxon.commonName ?? '',
externalSource: taxon.externalSource ?? '',
externalId: taxon.externalId ?? '',
};
}
export function toTaxonFormFromPlantInfo(result: PlantInfoSearchResult): TaxonFormState {
const canonicalName = result.canonicalName ?? result.scientificName;
return {
...emptyTaxonForm,
name: result.commonName ?? canonicalName,
genus: result.genus ?? '',
species: result.species ?? canonicalName.split(' ')[1] ?? '',
family: result.family ?? '',
commonName: result.commonName ?? '',
externalSource: result.source,
externalId: result.externalId,
};
}
export function toTaxonPayload(form: TaxonFormState): PlantTaxonPayload {
return {
name: form.name.trim(),
genus: form.genus.trim(),
species: form.species.trim(),
cultivar: form.cultivar.trim() || null,
variety: form.variety.trim() || null,
authority: form.authority.trim() || null,
family: form.family.trim() || null,
commonName: form.commonName.trim() || null,
externalSource: form.externalSource.trim() || null,
externalId: form.externalId.trim() || null,
};
}
export function toActionForm(action: CareAction): ActionFormState { export function toActionForm(action: CareAction): ActionFormState {
return { return {
name: action.name, name: action.name,
@@ -364,6 +304,22 @@ export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCar
}; };
} }
export function toBulkScheduleForm(schedule: PlantCareScheduleRule): BulkScheduleFormState {
return {
careActivityId: String(schedule.careActivityId),
everyDays: String(schedule.everyDays),
scheduledFor: schedule.scheduledFor ?? '',
recurrenceMode: schedule.recurrenceMode,
repeatEvery: String(schedule.repeatEvery),
repeatUnit: schedule.repeatUnit,
repeatOnDays: schedule.repeatOnDays ? schedule.repeatOnDays.split(',').filter(Boolean) : [],
endsMode: schedule.endsMode,
endsOn: schedule.endsOn ?? '',
endsAfterOccurrences: schedule.endsAfterOccurrences === null ? '12' : String(schedule.endsAfterOccurrences),
plantIds: schedule.plants.map((plant) => String(plant.id)),
};
}
export function formatTaxon(taxon: PlantTaxon) { export function formatTaxon(taxon: PlantTaxon) {
const botanical = `${taxon.genus} ${taxon.species}`.trim(); const botanical = `${taxon.genus} ${taxon.species}`.trim();
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`; return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
+138
View File
@@ -1013,6 +1013,47 @@ dd {
justify-self: start; justify-self: start;
} }
.activity-resource-detail-list {
display: grid;
gap: 8px;
margin-top: 8px;
}
.activity-resource-detail > p {
margin: 0;
}
.recipe-procedure {
display: grid;
gap: 5px;
margin-top: 5px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #f8f6ee;
}
.recipe-procedure h5 {
margin: 0;
color: #24342c;
font-size: 0.84rem;
}
.recipe-procedure ol {
display: grid;
gap: 3px;
margin: 0;
padding-left: 18px;
}
.recipe-procedure p,
.recipe-procedure li {
margin: 0;
color: #59675d;
font-size: 0.82rem;
line-height: 1.3;
}
.activity-action-cell { .activity-action-cell {
padding: 8px 0 8px 12px; padding: 8px 0 8px 12px;
} }
@@ -1168,6 +1209,95 @@ dd {
margin-top: 6px; margin-top: 6px;
} }
.care-mode-switch {
display: inline-flex;
width: fit-content;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.care-mode-switch button {
border: 0;
border-right: 1px solid var(--border);
border-radius: 0;
background: transparent;
color: var(--muted);
padding: 8px 14px;
}
.care-mode-switch button:last-child {
border-right: 0;
}
.care-mode-switch button[aria-selected="true"] {
background: var(--ink);
color: var(--surface);
}
.care-layout {
display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
}
.care-subpanel {
margin: 0;
}
.care-resource-edit {
display: grid;
grid-template-columns: minmax(90px, 120px) minmax(90px, 140px);
gap: 8px;
margin-top: 8px;
}
.care-notes {
display: grid;
gap: 8px;
}
.care-notes textarea {
min-height: 96px;
resize: vertical;
}
.dashboard-panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.dashboard-panel {
margin: 0;
}
.dashboard-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.dashboard-stat {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-muted);
}
.dashboard-stat strong {
font-size: 1.75rem;
line-height: 1;
}
.dashboard-stat span {
color: var(--muted);
font-size: 0.85rem;
}
.plant-detail-meta, .plant-detail-meta,
.plant-detail-grid { .plant-detail-grid {
display: grid; display: grid;
@@ -1499,6 +1629,14 @@ dd {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.care-layout {
grid-template-columns: 1fr;
}
.dashboard-panel-grid {
grid-template-columns: 1fr;
}
.catalog-nav { .catalog-nav {
display: flex; display: flex;
width: 100%; width: 100%;
+64 -45
View File
@@ -6,42 +6,42 @@ import {
createActionResource, createActionResource,
createCareAction, createCareAction,
createCareActivity, createCareActivity,
createActionLogsBulk,
createPlantCareSchedule,
createPlant, createPlant,
createPlantFlag, createPlantFlag,
createPlantGroup, createPlantGroup,
createPlantLocation, createPlantLocation,
createPlantTaxon,
createRecipe, createRecipe,
deleteActionResource, deleteActionResource,
deleteCareAction, deleteCareAction,
deleteCareActivity, deleteCareActivity,
deletePlant, deletePlant,
deletePlantCareSchedule,
deletePlantFlag, deletePlantFlag,
deletePlantGroup, deletePlantGroup,
deletePlantLocation, deletePlantLocation,
deletePlantTaxon, deletePlantTaxon,
deleteRecipe, deleteRecipe,
dismissCareTasksBulk,
downloadSpreadsheetExport, downloadSpreadsheetExport,
importPlantTaxon, importPlantTaxon,
previewCatalogImport, previewCatalogImport,
removePlantCareSchedulesBulk,
removePlantFlagAssignment, removePlantFlagAssignment,
resolvePlantFlag, resolvePlantFlag,
savePlantCareSchedulesBulk,
searchPlantInfo, searchPlantInfo,
updateActionResource, updateActionResource,
updateCareAction, updateCareAction,
updateCareActivity, updateCareActivity,
updatePlant, updatePlant,
updatePlantCareSchedule,
updatePlantFlag, updatePlantFlag,
updatePlantGroup, updatePlantGroup,
updatePlantLocation, updatePlantLocation,
updatePlantTaxon,
updateRecipe, updateRecipe,
} from './api'; } from './api';
import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain'; import type { BulkCompleteCareTasksPayload, CareTask, CatalogImportResult, DismissCareTasksPayload, Plant, PlantCareScheduleRule, PlantFlag, PlantInfoSearchResult, PlantTaxon } from './domain';
import { import {
emptyBulkScheduleForm,
emptyPlantFlagForm, emptyPlantFlagForm,
toActionPayload, toActionPayload,
toActivityPayload, toActivityPayload,
@@ -53,7 +53,6 @@ import {
toPlantPayload, toPlantPayload,
toRecipePayload, toRecipePayload,
toResourcePayload, toResourcePayload,
toTaxonPayload,
} from './form-state'; } from './form-state';
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';
@@ -222,29 +221,6 @@ export function useAppActions({
} }
} }
async function saveTaxon() {
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
setError('Name, genus, and species are required.');
return;
}
setIsSaving(true);
try {
const payload = toTaxonPayload(editors.taxonForm);
if (editors.editingTaxonId === null) {
await createPlantTaxon(payload);
} else {
await updatePlantTaxon(editors.editingTaxonId, payload);
}
editors.cancelEditingTaxon();
await loadTaxaAndPlants();
} catch {
setError('Could not save the taxon.');
} finally {
setIsSaving(false);
}
}
async function searchTaxonInfo( async function searchTaxonInfo(
queryOverride?: string, queryOverride?: string,
options: { updateQuery?: boolean } = {}, options: { updateQuery?: boolean } = {},
@@ -301,7 +277,7 @@ export function useAppActions({
} }
} }
async function removeTaxon(taxon: Parameters<typeof editors.startEditingTaxon>[0]) { async function removeTaxon(taxon: PlantTaxon) {
const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`); const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`);
if (!confirmed) { if (!confirmed) {
return; return;
@@ -310,9 +286,6 @@ export function useAppActions({
setIsSaving(true); setIsSaving(true);
try { try {
await deletePlantTaxon(taxon.id); await deletePlantTaxon(taxon.id);
if (editors.editingTaxonId === taxon.id) {
editors.cancelEditingTaxon();
}
await loadTaxaAndPlants(); await loadTaxaAndPlants();
} catch { } catch {
setError('Could not delete the taxon. It may still be used by a plant.'); setError('Could not delete the taxon. It may still be used by a plant.');
@@ -618,29 +591,36 @@ export function useAppActions({
setIsSaving(true); setIsSaving(true);
try { try {
await savePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm)); const payload = toBulkSchedulePayload(editors.bulkScheduleForm);
editors.setBulkScheduleForm(emptyBulkScheduleForm); if (editors.editingScheduleId === null) {
await createPlantCareSchedule(payload);
} else {
await updatePlantCareSchedule(editors.editingScheduleId, payload);
}
editors.cancelEditingSchedule();
await loadPlantsAndCareTasks(); await loadPlantsAndCareTasks();
} catch { } catch {
setError('Could not apply the care schedule.'); setError('Could not save the care schedule.');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
} }
async function removeBulkSchedule() { async function removeSchedule(schedule: PlantCareScheduleRule) {
if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) { const confirmed = window.confirm(`Delete ${schedule.action} schedule?`);
setError('Select an activity and at least one plant.'); if (!confirmed) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
try { try {
await removePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm)); await deletePlantCareSchedule(schedule.id);
editors.setBulkScheduleForm(emptyBulkScheduleForm); if (editors.editingScheduleId === schedule.id) {
editors.cancelEditingSchedule();
}
await loadPlantsAndCareTasks(); await loadPlantsAndCareTasks();
} catch { } catch {
setError('Could not remove the care schedule.'); setError('Could not delete the care schedule.');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -769,6 +749,44 @@ export function useAppActions({
} }
} }
async function logCare(payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) {
if (payload.plantIds.length === 0 || !payload.careActivityId) {
setError('Select an activity and at least one plant.');
return;
}
setIsSaving(true);
try {
if (requireDueSchedule) {
await completeCareTasksBulk(payload);
} else {
await createActionLogsBulk(payload);
}
await loadPlantsAndCareTasks();
} catch {
setError('Could not log care.');
} finally {
setIsSaving(false);
}
}
async function dismissCare(payload: DismissCareTasksPayload) {
if (payload.plantIds.length === 0 || !payload.careActivityId) {
setError('Select an activity and at least one plant.');
return;
}
setIsSaving(true);
try {
await dismissCareTasksBulk(payload);
await loadPlantsAndCareTasks();
} catch {
setError('Could not dismiss care.');
} finally {
setIsSaving(false);
}
}
return { return {
assignFlagToSelectedPlant, assignFlagToSelectedPlant,
applyCatalogImportFile, applyCatalogImportFile,
@@ -776,18 +794,20 @@ export function useAppActions({
completeBulkTasks, completeBulkTasks,
completeTask, completeTask,
exportSpreadsheet, exportSpreadsheet,
dismissCare,
isImportingCatalog, isImportingCatalog,
hasSearchedPlantInfo, hasSearchedPlantInfo,
isExporting, isExporting,
isSearchingPlantInfo, isSearchingPlantInfo,
isSaving, isSaving,
logCare,
importTaxonFromPlantInfo, importTaxonFromPlantInfo,
plantInfoQuery, plantInfoQuery,
plantInfoResults, plantInfoResults,
removeAction, removeAction,
removeActivity, removeActivity,
removeAssignedPlantFlag, removeAssignedPlantFlag,
removeBulkSchedule, removeSchedule,
removeFlagDefinition, removeFlagDefinition,
removeLocation, removeLocation,
removePlant, removePlant,
@@ -805,7 +825,6 @@ export function useAppActions({
savePlantGroup, savePlantGroup,
saveRecipe, saveRecipe,
saveResource, saveResource,
saveTaxon,
searchTaxonInfo, searchTaxonInfo,
previewCatalogImportFile, previewCatalogImportFile,
setPlantInfoQuery, setPlantInfoQuery,
+25 -53
View File
@@ -4,7 +4,7 @@ import type {
CareActivity, CareActivity,
CareAction, CareAction,
Plant, Plant,
PlantInfoSearchResult, PlantCareScheduleRule,
PlantFlagDefinition, PlantFlagDefinition,
PlantGroup, PlantGroup,
PlantLocation, PlantLocation,
@@ -22,17 +22,15 @@ import {
emptyPlantFlagForm, emptyPlantFlagForm,
emptyRecipeForm, emptyRecipeForm,
emptyResourceForm, emptyResourceForm,
emptyTaxonForm,
toActionForm, toActionForm,
toActivityForm, toActivityForm,
toBulkScheduleForm,
toFlagDefinitionForm, toFlagDefinitionForm,
toLocationForm, toLocationForm,
toPlantForm, toPlantForm,
toPlantGroupForm, toPlantGroupForm,
toRecipeForm, toRecipeForm,
toResourceForm, toResourceForm,
toTaxonForm,
toTaxonFormFromPlantInfo,
type ActionFormState, type ActionFormState,
type ActivityFormState, type ActivityFormState,
type BulkScheduleFormState, type BulkScheduleFormState,
@@ -43,7 +41,6 @@ import {
type PlantFlagFormState, type PlantFlagFormState,
type RecipeFormState, type RecipeFormState,
type ResourceFormState, type ResourceFormState,
type TaxonFormState,
type View, type View,
} from './form-state'; } from './form-state';
@@ -61,7 +58,6 @@ type AppEditorData = {
export function useAppEditors(data: AppEditorData, setView: (view: View) => void) { export function useAppEditors(data: AppEditorData, setView: (view: View) => void) {
const [editingPlantId, setEditingPlantId] = useState<number | null>(null); const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
const [editingLocationId, setEditingLocationId] = useState<number | null>(null); const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null); const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
const [editingActionId, setEditingActionId] = useState<number | null>(null); const [editingActionId, setEditingActionId] = useState<number | null>(null);
@@ -69,8 +65,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null); const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
const [editingActivityId, setEditingActivityId] = useState<number | null>(null); const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null); const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
const [editingScheduleId, setEditingScheduleId] = useState<number | null>(null);
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false); const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false); const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
@@ -87,7 +83,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null); const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null); const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
const [form, setForm] = useState<PlantFormState>(emptyPlantForm); const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm); const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm); const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm); const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
@@ -100,7 +95,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const activePlant = data.plants.find((plant) => plant.id === editingPlantId); const activePlant = data.plants.find((plant) => plant.id === editingPlantId);
const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId); const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId);
const activeTaxon = data.plantTaxa.find((taxon) => taxon.id === editingTaxonId);
const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId); const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId); const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId);
const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId); const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId);
@@ -120,10 +114,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
setForm((current) => ({ ...current, [field]: value })); setForm((current) => ({ ...current, [field]: value }));
} }
function updateTaxonForm(field: keyof TaxonFormState, value: string) {
setTaxonForm((current) => ({ ...current, [field]: value }));
}
function updateLocationForm(field: keyof LocationFormState, value: string) { function updateLocationForm(field: keyof LocationFormState, value: string) {
setLocationForm((current) => ({ ...current, [field]: value })); setLocationForm((current) => ({ ...current, [field]: value }));
} }
@@ -163,6 +153,23 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
setBulkScheduleForm((current) => ({ ...current, [field]: value })); setBulkScheduleForm((current) => ({ ...current, [field]: value }));
} }
function startNewSchedule() {
setEditingScheduleId(null);
setBulkScheduleForm(emptyBulkScheduleForm);
setView('schedules');
}
function startEditingSchedule(schedule: PlantCareScheduleRule) {
setEditingScheduleId(schedule.id);
setBulkScheduleForm(toBulkScheduleForm(schedule));
setView('schedules');
}
function cancelEditingSchedule() {
setEditingScheduleId(null);
setBulkScheduleForm(emptyBulkScheduleForm);
}
function startAddingPlant() { function startAddingPlant() {
setEditingPlantId(null); setEditingPlantId(null);
setSelectedPlantId(null); setSelectedPlantId(null);
@@ -195,36 +202,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
setPlantFlagForm(emptyPlantFlagForm); setPlantFlagForm(emptyPlantFlagForm);
} }
function startAddingTaxon() {
setEditingTaxonId(null);
setSelectedTaxonId(null);
setTaxonForm(emptyTaxonForm);
setIsTaxonEditorOpen(true);
setView('taxa');
}
function startEditingTaxon(taxon: PlantTaxon) {
setSelectedTaxonId(null);
setEditingTaxonId(taxon.id);
setTaxonForm(toTaxonForm(taxon));
setIsTaxonEditorOpen(true);
setView('taxa');
}
function startAddingTaxonFromPlantInfo(result: PlantInfoSearchResult) {
setEditingTaxonId(null);
setSelectedTaxonId(null);
setTaxonForm(toTaxonFormFromPlantInfo(result));
setIsTaxonEditorOpen(true);
setView('taxa');
}
function cancelEditingTaxon() {
setEditingTaxonId(null);
setTaxonForm(emptyTaxonForm);
setIsTaxonEditorOpen(false);
}
function startAddingLocation() { function startAddingLocation() {
setEditingLocationId(null); setEditingLocationId(null);
setSelectedLocationId(null); setSelectedLocationId(null);
@@ -406,9 +383,9 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
activePlantGroup, activePlantGroup,
activeRecipe, activeRecipe,
activeResource, activeResource,
activeTaxon,
activityForm, activityForm,
bulkScheduleForm, bulkScheduleForm,
cancelEditingSchedule,
cancelEditing, cancelEditing,
cancelEditingAction, cancelEditingAction,
cancelEditingActivity, cancelEditingActivity,
@@ -417,7 +394,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
cancelEditingPlantGroup, cancelEditingPlantGroup,
cancelEditingRecipe, cancelEditingRecipe,
cancelEditingResource, cancelEditingResource,
cancelEditingTaxon,
editingActionId, editingActionId,
editingActivityId, editingActivityId,
editingFlagDefinitionId, editingFlagDefinitionId,
@@ -426,7 +402,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
editingPlantId, editingPlantId,
editingRecipeId, editingRecipeId,
editingResourceId, editingResourceId,
editingTaxonId, editingScheduleId,
flagDefinitionForm, flagDefinitionForm,
form, form,
isActionEditorOpen, isActionEditorOpen,
@@ -437,7 +413,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
isPlantGroupEditorOpen, isPlantGroupEditorOpen,
isRecipeEditorOpen, isRecipeEditorOpen,
isResourceEditorOpen, isResourceEditorOpen,
isTaxonEditorOpen,
locationForm, locationForm,
openPlantDetail, openPlantDetail,
plantFlagForm, plantFlagForm,
@@ -464,7 +439,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
setEditingPlantId, setEditingPlantId,
setEditingRecipeId, setEditingRecipeId,
setEditingResourceId, setEditingResourceId,
setEditingTaxonId, setEditingScheduleId,
setPlantFlagForm, setPlantFlagForm,
setSelectedActionId, setSelectedActionId,
setSelectedActivityId, setSelectedActivityId,
@@ -482,8 +457,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
startAddingPlantGroup, startAddingPlantGroup,
startAddingRecipe, startAddingRecipe,
startAddingResource, startAddingResource,
startAddingTaxon,
startAddingTaxonFromPlantInfo,
startEditingAction, startEditingAction,
startEditingActivity, startEditingActivity,
startEditingFlagDefinition, startEditingFlagDefinition,
@@ -492,8 +465,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
startEditingPlantGroup, startEditingPlantGroup,
startEditingRecipe, startEditingRecipe,
startEditingResource, startEditingResource,
startEditingTaxon, startEditingSchedule,
taxonForm, startNewSchedule,
updateActionForm, updateActionForm,
updateActivityForm, updateActivityForm,
updateBulkScheduleForm, updateBulkScheduleForm,
@@ -504,6 +477,5 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
updatePlantGroupForm, updatePlantGroupForm,
updateRecipeForm, updateRecipeForm,
updateResourceForm, updateResourceForm,
updateTaxonForm,
}; };
} }
+13 -1
View File
@@ -5,6 +5,7 @@ import {
getCareActions, getCareActions,
getCareTasks, getCareTasks,
getPlants, getPlants,
getPlantCareSchedules,
getPlantFlags, getPlantFlags,
getPlantGroups, getPlantGroups,
getPlantLocations, getPlantLocations,
@@ -17,6 +18,7 @@ import type {
CareAction, CareAction,
CareTask, CareTask,
Plant, Plant,
PlantCareScheduleRule,
PlantFlagDefinition, PlantFlagDefinition,
PlantGroup, PlantGroup,
PlantLocation, PlantLocation,
@@ -33,6 +35,7 @@ export function useDashboardData() {
const [actionResources, setActionResources] = useState<ActionResource[]>([]); const [actionResources, setActionResources] = useState<ActionResource[]>([]);
const [recipes, setRecipes] = useState<Recipe[]>([]); const [recipes, setRecipes] = useState<Recipe[]>([]);
const [careActivities, setCareActivities] = useState<CareActivity[]>([]); const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
const [plantCareSchedules, setPlantCareSchedules] = useState<PlantCareScheduleRule[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]); const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]); const [careTasks, setCareTasks] = useState<CareTask[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -43,6 +46,7 @@ export function useDashboardData() {
const [ const [
plantsResponse, plantsResponse,
tasksResponse, tasksResponse,
schedulesResponse,
taxaResponse, taxaResponse,
locationsResponse, locationsResponse,
groupsResponse, groupsResponse,
@@ -54,6 +58,7 @@ export function useDashboardData() {
] = await Promise.all([ ] = await Promise.all([
getPlants(), getPlants(),
getCareTasks(), getCareTasks(),
getPlantCareSchedules(),
getPlantTaxa(), getPlantTaxa(),
getPlantLocations(), getPlantLocations(),
getPlantGroups(), getPlantGroups(),
@@ -67,6 +72,7 @@ export function useDashboardData() {
setError(null); setError(null);
setPlants(plantsResponse); setPlants(plantsResponse);
setCareTasks(tasksResponse); setCareTasks(tasksResponse);
setPlantCareSchedules(schedulesResponse);
setPlantTaxa(taxaResponse); setPlantTaxa(taxaResponse);
setPlantLocations(locationsResponse); setPlantLocations(locationsResponse);
setPlantGroups(groupsResponse); setPlantGroups(groupsResponse);
@@ -108,14 +114,16 @@ export function useDashboardData() {
} }
async function loadPlantsAndCareTasks() { async function loadPlantsAndCareTasks() {
const [plantsResponse, tasksResponse] = await Promise.all([ const [plantsResponse, tasksResponse, schedulesResponse] = await Promise.all([
getPlants(), getPlants(),
getCareTasks(), getCareTasks(),
getPlantCareSchedules(),
]); ]);
setError(null); setError(null);
setPlants(plantsResponse); setPlants(plantsResponse);
setCareTasks(tasksResponse); setCareTasks(tasksResponse);
setPlantCareSchedules(schedulesResponse);
} }
async function loadTaxaAndPlants() { async function loadTaxaAndPlants() {
@@ -144,6 +152,7 @@ export function useDashboardData() {
const [ const [
plantsResponse, plantsResponse,
tasksResponse, tasksResponse,
schedulesResponse,
actionsResponse, actionsResponse,
resourcesResponse, resourcesResponse,
recipesResponse, recipesResponse,
@@ -151,6 +160,7 @@ export function useDashboardData() {
] = await Promise.all([ ] = await Promise.all([
getPlants(), getPlants(),
getCareTasks(), getCareTasks(),
getPlantCareSchedules(),
getCareActions(), getCareActions(),
getActionResources(), getActionResources(),
getRecipes(), getRecipes(),
@@ -160,6 +170,7 @@ export function useDashboardData() {
setError(null); setError(null);
setPlants(plantsResponse); setPlants(plantsResponse);
setCareTasks(tasksResponse); setCareTasks(tasksResponse);
setPlantCareSchedules(schedulesResponse);
setCareActions(actionsResponse); setCareActions(actionsResponse);
setActionResources(resourcesResponse); setActionResources(resourcesResponse);
setRecipes(recipesResponse); setRecipes(recipesResponse);
@@ -206,6 +217,7 @@ export function useDashboardData() {
loadRecipesAndResources, loadRecipesAndResources,
loadTaxaAndPlants, loadTaxaAndPlants,
plantFlagDefinitions, plantFlagDefinitions,
plantCareSchedules,
plantGroups, plantGroups,
plantLocations, plantLocations,
plantTaxa, plantTaxa,
+124 -31
View File
@@ -18,6 +18,7 @@ namespace plant_manager
public record SavePlantCareScheduleRequest( public record SavePlantCareScheduleRequest(
int CareActivityId, int CareActivityId,
IReadOnlyList<int>? PlantIds,
int? EveryDays, int? EveryDays,
DateOnly? ScheduledFor, DateOnly? ScheduledFor,
string? RecurrenceMode, string? RecurrenceMode,
@@ -41,18 +42,6 @@ namespace plant_manager
DateOnly? EndsOn, DateOnly? EndsOn,
int? EndsAfterOccurrences); int? EndsAfterOccurrences);
public record SavePlantTaxonRequest(
string Name,
string Genus,
string Species,
string? Cultivar,
string? Variety,
string? Authority,
string? Family,
string? CommonName,
string? ExternalSource,
string? ExternalId);
public record ImportPlantTaxonRequest( public record ImportPlantTaxonRequest(
string Source, string Source,
string ExternalId, string ExternalId,
@@ -110,12 +99,50 @@ namespace plant_manager
string? Unit, string? Unit,
string? Notes); string? Notes);
public record CareActivityRecipeComponentDto(
int ActionResourceId,
string Name,
decimal? Quantity,
string? Unit,
string? Notes,
int SortOrder)
{
public static CareActivityRecipeComponentDto FromRecipeComponent(RecipeComponent component) =>
new(
component.ActionResourceId,
component.ActionResource.Name,
component.Quantity,
component.Unit,
component.Notes,
component.SortOrder);
}
public record CareActivityRecipeDto(
int Id,
string Name,
string MeasurementMode,
IReadOnlyList<CareActivityRecipeComponentDto> Components,
string? Notes)
{
public static CareActivityRecipeDto FromRecipe(Recipe recipe) =>
new(
recipe.Id,
recipe.Name,
recipe.MeasurementMode,
recipe.Components
.OrderBy(component => component.SortOrder)
.Select(CareActivityRecipeComponentDto.FromRecipeComponent)
.ToList(),
recipe.Notes);
}
public record CareActivityActionResourceDto( public record CareActivityActionResourceDto(
int ActionResourceId, int ActionResourceId,
string Name, string Name,
decimal? Quantity, decimal? Quantity,
string? Unit, string? Unit,
string? Notes) string? Notes,
CareActivityRecipeDto? ProducedByRecipe)
{ {
public static CareActivityActionResourceDto FromCareActivityActionResource( public static CareActivityActionResourceDto FromCareActivityActionResource(
CareActivityActionResource resource) => CareActivityActionResource resource) =>
@@ -124,7 +151,10 @@ namespace plant_manager
resource.ActionResource.Name, resource.ActionResource.Name,
resource.Quantity, resource.Quantity,
resource.Unit, resource.Unit,
resource.Notes); resource.Notes,
resource.ActionResource.ProducedByRecipe is null
? null
: CareActivityRecipeDto.FromRecipe(resource.ActionResource.ProducedByRecipe));
} }
public record CareActivityActionDto( public record CareActivityActionDto(
@@ -186,6 +216,12 @@ namespace plant_manager
string? Notes, string? Notes,
IReadOnlyList<ActionLogResourceRequest>? Resources); IReadOnlyList<ActionLogResourceRequest>? Resources);
public record DismissCareTasksRequest(
int CareActivityId,
IReadOnlyList<int> PlantIds,
DateOnly? DismissedOn,
string? Notes);
public record CatalogImportIssue( public record CatalogImportIssue(
string Sheet, string Sheet,
int Row, int Row,
@@ -401,21 +437,23 @@ namespace plant_manager
public static PlantDto FromPlant(Plant plant) public static PlantDto FromPlant(Plant plant)
{ {
var today = DateOnly.FromDateTime(DateTime.UtcNow); var today = DateOnly.FromDateTime(DateTime.UtcNow);
var schedules = plant.CareSchedules var schedules = plant.CareScheduleAssignments
.OrderBy(schedule => schedule.CareActivity.Name) .OrderBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
.Select(schedule => PlantCareScheduleDto.FromSchedule( .Select(assignment => PlantCareScheduleDto.FromAssignment(
schedule, assignment,
GetLatestPerformedOn(plant, schedule.CareActivityId), GetLatestCareEventOn(plant, assignment.PlantCareSchedule.CareActivityId),
today)) today))
.ToList(); .ToList();
var nextCare = schedules var nextCare = schedules
.Select(schedule => .Select(schedule =>
{ {
var source = plant.CareSchedules.First(item => item.Id == schedule.Id); var source = plant.CareScheduleAssignments
.Select(assignment => assignment.PlantCareSchedule)
.First(item => item.Id == schedule.Id);
return PlantCareFormatter.GetNextCareDate( return PlantCareFormatter.GetNextCareDate(
source, source,
schedule.LastPerformedOn, schedule.LastPerformedOn,
plant.ActionLogs.Count(log => log.CareActivityId == source.CareActivityId)); GetCompletedOccurrences(plant, source.CareActivityId));
}) })
.Where(date => date is not null) .Where(date => date is not null)
.OrderBy(date => date) .OrderBy(date => date)
@@ -449,11 +487,18 @@ namespace plant_manager
schedules); schedules);
} }
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) => private static DateOnly? GetLatestCareEventOn(Plant plant, int careActivityId) =>
plant.ActionLogs plant.ActionLogs
.Where(log => log.CareActivityId == careActivityId) .Where(log => log.CareActivityId == careActivityId)
.Select(log => (DateOnly?)log.PerformedOn) .Select(log => (DateOnly?)log.PerformedOn)
.Concat(plant.CareDismissals
.Where(dismissal => dismissal.CareActivityId == careActivityId)
.Select(dismissal => (DateOnly?)dismissal.DismissedOn))
.Max(); .Max();
private static int GetCompletedOccurrences(Plant plant, int careActivityId) =>
plant.ActionLogs.Count(log => log.CareActivityId == careActivityId)
+ plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == careActivityId);
} }
public record PlantCareScheduleDto( public record PlantCareScheduleDto(
@@ -475,12 +520,13 @@ namespace plant_manager
string NextCare, string NextCare,
string Status) string Status)
{ {
public static PlantCareScheduleDto FromSchedule( public static PlantCareScheduleDto FromAssignment(
PlantCareSchedule schedule, PlantCareScheduleAssignment assignment,
DateOnly? lastPerformedOn, DateOnly? lastPerformedOn,
DateOnly today) DateOnly today)
{ {
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(schedule)); var schedule = assignment.PlantCareSchedule;
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(assignment));
return new PlantCareScheduleDto( return new PlantCareScheduleDto(
schedule.Id, schedule.Id,
@@ -502,8 +548,54 @@ namespace plant_manager
PlantCareFormatter.GetStatus(nextCare, today)); PlantCareFormatter.GetStatus(nextCare, today));
} }
private static int GetCompletedOccurrences(PlantCareSchedule schedule) => private static int GetCompletedOccurrences(PlantCareScheduleAssignment assignment) =>
schedule.Plant.ActionLogs.Count(log => log.CareActivityId == schedule.CareActivityId); assignment.Plant.ActionLogs.Count(log => log.CareActivityId == assignment.PlantCareSchedule.CareActivityId)
+ assignment.Plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == assignment.PlantCareSchedule.CareActivityId);
}
public record PlantCareScheduleAssignmentDto(
int Id,
string Nickname)
{
public static PlantCareScheduleAssignmentDto FromAssignment(PlantCareScheduleAssignment assignment) =>
new(assignment.PlantId, assignment.Plant.Nickname);
}
public record PlantCareScheduleRuleDto(
int Id,
int CareActivityId,
int CareActionId,
string Action,
int EveryDays,
DateOnly? ScheduledFor,
string RecurrenceMode,
int RepeatEvery,
string RepeatUnit,
string? RepeatOnDays,
string EndsMode,
DateOnly? EndsOn,
int? EndsAfterOccurrences,
IReadOnlyList<PlantCareScheduleAssignmentDto> Plants)
{
public static PlantCareScheduleRuleDto FromSchedule(PlantCareSchedule schedule) =>
new(
schedule.Id,
schedule.CareActivityId,
schedule.CareActionId,
schedule.CareActivity.Name,
schedule.EveryDays,
schedule.ScheduledFor,
schedule.RecurrenceMode,
schedule.RepeatEvery,
schedule.RepeatUnit,
schedule.RepeatOnDays,
schedule.EndsMode,
schedule.EndsOn,
schedule.EndsAfterOccurrences,
schedule.Assignments
.OrderBy(assignment => assignment.Plant.Nickname)
.Select(PlantCareScheduleAssignmentDto.FromAssignment)
.ToList());
} }
internal static class CareActivityExtensions internal static class CareActivityExtensions
@@ -532,18 +624,19 @@ namespace plant_manager
string Due, string Due,
string Status) string Status)
{ {
public static CareTaskDto FromSchedule( public static CareTaskDto FromAssignment(
PlantCareSchedule schedule, PlantCareScheduleAssignment assignment,
DateOnly? lastPerformedOn, DateOnly? lastPerformedOn,
int completedOccurrences, int completedOccurrences,
DateOnly today) DateOnly today)
{ {
var schedule = assignment.PlantCareSchedule;
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences); var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
return new CareTaskDto( return new CareTaskDto(
schedule.Id, schedule.Id,
schedule.PlantId, assignment.PlantId,
schedule.Plant.Nickname, assignment.Plant.Nickname,
schedule.CareActivityId, schedule.CareActivityId,
schedule.CareActionId, schedule.CareActionId,
schedule.CareActivity.Name, schedule.CareActivity.Name,
+36 -9
View File
@@ -15,7 +15,9 @@ namespace plant_manager.Data
public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; } public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; } public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<ActionLogResource> ActionLogResources { get; set; } public DbSet<ActionLogResource> ActionLogResources { get; set; }
public DbSet<CareDismissal> CareDismissals { get; set; }
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; } public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
public DbSet<PlantCareScheduleAssignment> PlantCareScheduleAssignments { get; set; }
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; } public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
public DbSet<PlantFlag> PlantFlags { get; set; } public DbSet<PlantFlag> PlantFlags { get; set; }
public DbSet<Recipe> Recipes { get; set; } public DbSet<Recipe> Recipes { get; set; }
@@ -40,9 +42,9 @@ namespace plant_manager.Data
entity.Property(e => e.Authority).HasMaxLength(120); entity.Property(e => e.Authority).HasMaxLength(120);
entity.Property(e => e.Family).HasMaxLength(120); entity.Property(e => e.Family).HasMaxLength(120);
entity.Property(e => e.CommonName).HasMaxLength(120); entity.Property(e => e.CommonName).HasMaxLength(120);
entity.Property(e => e.ExternalSource).HasMaxLength(40); entity.Property(e => e.ExternalSource).HasMaxLength(40).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(80); entity.Property(e => e.ExternalId).HasMaxLength(80).IsRequired();
entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique(false); entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique();
}); });
modelBuilder.Entity<Plant>(entity => modelBuilder.Entity<Plant>(entity =>
@@ -165,6 +167,23 @@ namespace plant_manager.Data
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<CareDismissal>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasIndex(e => new { e.PlantId, e.CareActivityId, e.DismissedOn });
entity.HasOne(e => e.Plant)
.WithMany(e => e.CareDismissals)
.HasForeignKey(e => e.PlantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CareActivity)
.WithMany(e => e.CareDismissals)
.HasForeignKey(e => e.CareActivityId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<PlantCareSchedule>(entity => modelBuilder.Entity<PlantCareSchedule>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
@@ -179,12 +198,6 @@ namespace plant_manager.Data
entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired(); entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired();
entity.Property(e => e.EndsOn); entity.Property(e => e.EndsOn);
entity.Property(e => e.EndsAfterOccurrences); entity.Property(e => e.EndsAfterOccurrences);
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique(false);
entity.HasIndex(e => new { e.PlantId, e.CareActivityId }).IsUnique();
entity.HasOne(e => e.Plant)
.WithMany(e => e.CareSchedules)
.HasForeignKey(e => e.PlantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CareAction) entity.HasOne(e => e.CareAction)
.WithMany(e => e.PlantCareSchedules) .WithMany(e => e.PlantCareSchedules)
.HasForeignKey(e => e.CareActionId) .HasForeignKey(e => e.CareActionId)
@@ -195,6 +208,20 @@ namespace plant_manager.Data
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<PlantCareScheduleAssignment>(entity =>
{
entity.HasKey(e => new { e.PlantCareScheduleId, e.PlantId });
entity.HasIndex(e => new { e.PlantId, e.PlantCareScheduleId }).IsUnique();
entity.HasOne(e => e.PlantCareSchedule)
.WithMany(e => e.Assignments)
.HasForeignKey(e => e.PlantCareScheduleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Plant)
.WithMany(e => e.CareScheduleAssignments)
.HasForeignKey(e => e.PlantId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PlantFlagDefinition>(entity => modelBuilder.Entity<PlantFlagDefinition>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
@@ -11,7 +11,7 @@ using plant_manager.Data;
namespace plant_manager.Data.Migrations namespace plant_manager.Data.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20260612235128_InitialCreate")] [Migration("20260614222157_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -201,6 +201,34 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActivityActionResources"); b.ToTable("CareActivityActionResources");
}); });
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("DismissedOn")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
b.ToTable("CareDismissals");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b => modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -256,9 +284,6 @@ namespace plant_manager.Data.Migrations
b.Property<int>("EveryDays") b.Property<int>("EveryDays")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<string>("RecurrenceMode") b.Property<string>("RecurrenceMode")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
@@ -285,12 +310,23 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActivityId"); b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActionId"); b.ToTable("PlantCareSchedules");
});
b.HasIndex("PlantId", "CareActivityId") modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
{
b.Property<int>("PlantCareScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.HasKey("PlantCareScheduleId", "PlantId");
b.HasIndex("PlantId", "PlantCareScheduleId")
.IsUnique(); .IsUnique();
b.ToTable("PlantCareSchedules"); b.ToTable("PlantCareScheduleAssignments");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -428,10 +464,12 @@ namespace plant_manager.Data.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalId") b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalSource") b.Property<string>("ExternalSource")
.IsRequired()
.HasMaxLength(40) .HasMaxLength(40)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -460,7 +498,8 @@ namespace plant_manager.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ExternalSource", "ExternalId"); b.HasIndex("ExternalSource", "ExternalId")
.IsUnique();
b.ToTable("PlantTaxa"); b.ToTable("PlantTaxa");
}); });
@@ -616,6 +655,25 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareActivityAction"); b.Navigation("CareActivityAction");
}); });
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
{
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("CareDismissals")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareDismissals")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareActivity");
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b => modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{ {
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location") b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -647,17 +705,28 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
{
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
.WithMany("Assignments")
.HasForeignKey("PlantCareScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant") b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSchedules") .WithMany("CareScheduleAssignments")
.HasForeignKey("PlantId") .HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant"); b.Navigation("Plant");
b.Navigation("PlantCareSchedule");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -758,6 +827,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("Actions"); b.Navigation("Actions");
b.Navigation("CareDismissals");
b.Navigation("PlantCareSchedules"); b.Navigation("PlantCareSchedules");
}); });
@@ -770,13 +841,20 @@ namespace plant_manager.Data.Migrations
{ {
b.Navigation("ActionLogs"); b.Navigation("ActionLogs");
b.Navigation("CareSchedules"); b.Navigation("CareDismissals");
b.Navigation("CareScheduleAssignments");
b.Navigation("Flags"); b.Navigation("Flags");
b.Navigation("GroupMemberships"); b.Navigation("GroupMemberships");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
{
b.Navigation("Assignments");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
{ {
b.Navigation("PlantFlags"); b.Navigation("PlantFlags");
@@ -109,8 +109,8 @@ namespace plant_manager.Data.Migrations
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true), Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true), Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true), CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true), ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true) ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -164,6 +164,41 @@ namespace plant_manager.Data.Migrations
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "PlantCareSchedules",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
table.ForeignKey(
name: "FK_PlantCareSchedules_CareActions_CareActionId",
column: x => x.CareActionId,
principalTable: "CareActions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
column: x => x.CareActivityId,
principalTable: "CareActivities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Plants", name: "Plants",
columns: table => new columns: table => new
@@ -285,41 +320,51 @@ namespace plant_manager.Data.Migrations
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "PlantCareSchedules", name: "CareDismissals",
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "INTEGER", nullable: false) Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true), .Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false), PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false), CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
EveryDays = table.Column<int>(type: "INTEGER", nullable: false), DismissedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true), Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id); table.PrimaryKey("PK_CareDismissals", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_PlantCareSchedules_CareActions_CareActionId", name: "FK_CareDismissals_CareActivities_CareActivityId",
column: x => x.CareActionId,
principalTable: "CareActions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
column: x => x.CareActivityId, column: x => x.CareActivityId,
principalTable: "CareActivities", principalTable: "CareActivities",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Restrict); onDelete: ReferentialAction.Restrict);
table.ForeignKey( table.ForeignKey(
name: "FK_PlantCareSchedules_Plants_PlantId", name: "FK_CareDismissals_Plants_PlantId",
column: x => x.PlantId,
principalTable: "Plants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PlantCareScheduleAssignments",
columns: table => new
{
PlantCareScheduleId = table.Column<int>(type: "INTEGER", nullable: false),
PlantId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantCareScheduleAssignments", x => new { x.PlantCareScheduleId, x.PlantId });
table.ForeignKey(
name: "FK_PlantCareScheduleAssignments_PlantCareSchedules_PlantCareScheduleId",
column: x => x.PlantCareScheduleId,
principalTable: "PlantCareSchedules",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlantCareScheduleAssignments_Plants_PlantId",
column: x => x.PlantId, column: x => x.PlantId,
principalTable: "Plants", principalTable: "Plants",
principalColumn: "Id", principalColumn: "Id",
@@ -459,6 +504,22 @@ namespace plant_manager.Data.Migrations
columns: new[] { "CareActivityId", "SortOrder" }, columns: new[] { "CareActivityId", "SortOrder" },
unique: true); unique: true);
migrationBuilder.CreateIndex(
name: "IX_CareDismissals_CareActivityId",
table: "CareDismissals",
column: "CareActivityId");
migrationBuilder.CreateIndex(
name: "IX_CareDismissals_PlantId_CareActivityId_DismissedOn",
table: "CareDismissals",
columns: new[] { "PlantId", "CareActivityId", "DismissedOn" });
migrationBuilder.CreateIndex(
name: "IX_PlantCareScheduleAssignments_PlantId_PlantCareScheduleId",
table: "PlantCareScheduleAssignments",
columns: new[] { "PlantId", "PlantCareScheduleId" },
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_CareActionId", name: "IX_PlantCareSchedules_CareActionId",
table: "PlantCareSchedules", table: "PlantCareSchedules",
@@ -469,17 +530,6 @@ namespace plant_manager.Data.Migrations
table: "PlantCareSchedules", table: "PlantCareSchedules",
column: "CareActivityId"); column: "CareActivityId");
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_PlantId_CareActionId",
table: "PlantCareSchedules",
columns: new[] { "PlantId", "CareActionId" });
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_PlantId_CareActivityId",
table: "PlantCareSchedules",
columns: new[] { "PlantId", "CareActivityId" },
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_PlantFlagDefinitions_Name", name: "IX_PlantFlagDefinitions_Name",
table: "PlantFlagDefinitions", table: "PlantFlagDefinitions",
@@ -526,7 +576,8 @@ namespace plant_manager.Data.Migrations
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_PlantTaxa_ExternalSource_ExternalId", name: "IX_PlantTaxa_ExternalSource_ExternalId",
table: "PlantTaxa", table: "PlantTaxa",
columns: new[] { "ExternalSource", "ExternalId" }); columns: new[] { "ExternalSource", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_RecipeComponents_ActionResourceId", name: "IX_RecipeComponents_ActionResourceId",
@@ -562,7 +613,10 @@ namespace plant_manager.Data.Migrations
name: "CareActivityActionResources"); name: "CareActivityActionResources");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "PlantCareSchedules"); name: "CareDismissals");
migrationBuilder.DropTable(
name: "PlantCareScheduleAssignments");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "PlantFlags"); name: "PlantFlags");
@@ -579,6 +633,9 @@ namespace plant_manager.Data.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "CareActivityActions"); name: "CareActivityActions");
migrationBuilder.DropTable(
name: "PlantCareSchedules");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "PlantFlagDefinitions"); name: "PlantFlagDefinitions");
@@ -198,6 +198,34 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActivityActionResources"); b.ToTable("CareActivityActionResources");
}); });
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("DismissedOn")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
b.ToTable("CareDismissals");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b => modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -253,9 +281,6 @@ namespace plant_manager.Data.Migrations
b.Property<int>("EveryDays") b.Property<int>("EveryDays")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<string>("RecurrenceMode") b.Property<string>("RecurrenceMode")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
@@ -282,12 +307,23 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActivityId"); b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActionId"); b.ToTable("PlantCareSchedules");
});
b.HasIndex("PlantId", "CareActivityId") modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
{
b.Property<int>("PlantCareScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.HasKey("PlantCareScheduleId", "PlantId");
b.HasIndex("PlantId", "PlantCareScheduleId")
.IsUnique(); .IsUnique();
b.ToTable("PlantCareSchedules"); b.ToTable("PlantCareScheduleAssignments");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -425,10 +461,12 @@ namespace plant_manager.Data.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalId") b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalSource") b.Property<string>("ExternalSource")
.IsRequired()
.HasMaxLength(40) .HasMaxLength(40)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -457,7 +495,8 @@ namespace plant_manager.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ExternalSource", "ExternalId"); b.HasIndex("ExternalSource", "ExternalId")
.IsUnique();
b.ToTable("PlantTaxa"); b.ToTable("PlantTaxa");
}); });
@@ -613,6 +652,25 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareActivityAction"); b.Navigation("CareActivityAction");
}); });
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
{
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("CareDismissals")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareDismissals")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareActivity");
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b => modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{ {
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location") b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -644,17 +702,28 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
{
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
.WithMany("Assignments")
.HasForeignKey("PlantCareScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant") b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSchedules") .WithMany("CareScheduleAssignments")
.HasForeignKey("PlantId") .HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant"); b.Navigation("Plant");
b.Navigation("PlantCareSchedule");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -755,6 +824,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("Actions"); b.Navigation("Actions");
b.Navigation("CareDismissals");
b.Navigation("PlantCareSchedules"); b.Navigation("PlantCareSchedules");
}); });
@@ -767,13 +838,20 @@ namespace plant_manager.Data.Migrations
{ {
b.Navigation("ActionLogs"); b.Navigation("ActionLogs");
b.Navigation("CareSchedules"); b.Navigation("CareDismissals");
b.Navigation("CareScheduleAssignments");
b.Navigation("Flags"); b.Navigation("Flags");
b.Navigation("GroupMemberships"); b.Navigation("GroupMemberships");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
{
b.Navigation("Assignments");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
{ {
b.Navigation("PlantFlags"); b.Navigation("PlantFlags");
@@ -9,6 +9,7 @@ namespace plant_manager.Data.Models
public List<CareActivityAction> Actions { get; set; } = []; public List<CareActivityAction> Actions { get; set; } = [];
public List<ActionLog> ActionLogs { get; set; } = []; public List<ActionLog> ActionLogs { get; set; } = [];
public List<CareDismissal> CareDismissals { get; set; } = [];
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = []; public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
} }
} }
@@ -0,0 +1,14 @@
namespace plant_manager.Data.Models
{
public class CareDismissal
{
public int Id { get; set; }
public int PlantId { get; set; }
public int CareActivityId { get; set; }
public DateOnly DismissedOn { get; set; }
public string? Notes { get; set; }
public Plant Plant { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!;
}
}
+2 -1
View File
@@ -11,7 +11,8 @@ namespace plant_manager.Data.Models
public PlantTaxon? Taxon { get; set; } public PlantTaxon? Taxon { get; set; }
public PlantLocation? Location { get; set; } public PlantLocation? Location { get; set; }
public List<ActionLog> ActionLogs { get; set; } = []; public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> CareSchedules { get; set; } = []; public List<CareDismissal> CareDismissals { get; set; } = [];
public List<PlantCareScheduleAssignment> CareScheduleAssignments { get; set; } = [];
public List<PlantFlag> Flags { get; set; } = []; public List<PlantFlag> Flags { get; set; } = [];
public List<PlantGroupMembership> GroupMemberships { get; set; } = []; public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
} }
@@ -3,7 +3,6 @@ namespace plant_manager.Data.Models
public class PlantCareSchedule public class PlantCareSchedule
{ {
public int Id { get; set; } public int Id { get; set; }
public int PlantId { get; set; }
public int CareActionId { get; set; } public int CareActionId { get; set; }
public int CareActivityId { get; set; } public int CareActivityId { get; set; }
public int EveryDays { get; set; } = 7; public int EveryDays { get; set; } = 7;
@@ -16,8 +15,8 @@ namespace plant_manager.Data.Models
public DateOnly? EndsOn { get; set; } public DateOnly? EndsOn { get; set; }
public int? EndsAfterOccurrences { get; set; } public int? EndsAfterOccurrences { get; set; }
public Plant Plant { get; set; } = null!;
public CareAction CareAction { get; set; } = null!; public CareAction CareAction { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!; public CareActivity CareActivity { get; set; } = null!;
public List<PlantCareScheduleAssignment> Assignments { get; set; } = [];
} }
} }
@@ -0,0 +1,11 @@
namespace plant_manager.Data.Models
{
public class PlantCareScheduleAssignment
{
public int PlantCareScheduleId { get; set; }
public int PlantId { get; set; }
public PlantCareSchedule PlantCareSchedule { get; set; } = null!;
public Plant Plant { get; set; } = null!;
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ namespace plant_manager.Data.Models
public string? Authority { get; set; } public string? Authority { get; set; }
public string? Family { get; set; } public string? Family { get; set; }
public string? CommonName { get; set; } public string? CommonName { get; set; }
public string? ExternalSource { get; set; } public string ExternalSource { get; set; } = string.Empty;
public string? ExternalId { get; set; } public string ExternalId { get; set; } = string.Empty;
} }
} }
+514
View File
@@ -0,0 +1,514 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data.Models;
namespace plant_manager.Data
{
public static class SeedData
{
public static async Task SeedDevelopmentDataAsync(this ApplicationDbContext db)
{
if (await db.Plants.AnyAsync())
{
return;
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var taxa = new
{
Monstera = new PlantTaxon
{
Name = "Swiss Cheese Plant",
Genus = "Monstera",
Species = "deliciosa",
Family = "Araceae",
CommonName = "Swiss Cheese Plant",
ExternalSource = "gbif",
ExternalId = "2871984"
},
Ficus = new PlantTaxon
{
Name = "Fiddle-leaf Fig",
Genus = "Ficus",
Species = "lyrata",
Family = "Moraceae",
CommonName = "Fiddle-leaf Fig",
ExternalSource = "gbif",
ExternalId = "5361909"
},
Alocasia = new PlantTaxon
{
Name = "Kris Plant",
Genus = "Alocasia",
Species = "sanderiana",
Family = "Araceae",
CommonName = "Kris Plant",
ExternalSource = "gbif",
ExternalId = "2879681"
},
Peperomia = new PlantTaxon
{
Name = "Marble Peperomia",
Genus = "Peperomia",
Species = "obtusifolia",
Cultivar = "Marble",
Family = "Piperaceae",
CommonName = "Marble Peperomia",
ExternalSource = "gbif",
ExternalId = "4189935"
},
Pothos = new PlantTaxon
{
Name = "Pothos",
Genus = "Epipremnum",
Species = "aureum",
Family = "Araceae",
CommonName = "Pothos",
ExternalSource = "gbif",
ExternalId = "2868275"
},
Orchid = new PlantTaxon
{
Name = "Moth Orchid",
Genus = "Phalaenopsis",
Species = "hybrid",
Family = "Orchidaceae",
CommonName = "Moth Orchid",
ExternalSource = "gbif",
ExternalId = "2879487"
}
};
var locations = new
{
LivingRoom = new PlantLocation { Name = "Living Room", Notes = "Bright indirect light near the east window." },
Office = new PlantLocation { Name = "Office", Notes = "Grow light shelf." },
Kitchen = new PlantLocation { Name = "Kitchen", Notes = "Higher humidity and morning light." },
Quarantine = new PlantLocation { Name = "Quarantine Shelf", Notes = "Temporary isolation and observation." }
};
var actions = new
{
Water = new CareAction { Name = "Water", Description = "Apply water or prepared solution." },
Fertilize = new CareAction { Name = "Fertilize", Description = "Apply nutrients at the selected dilution." },
Inspect = new CareAction { Name = "Inspect", Description = "Check foliage, roots, and pest pressure." },
Repot = new CareAction { Name = "Repot", Description = "Refresh substrate or move to a larger pot." },
Prune = new CareAction { Name = "Prune", Description = "Trim damaged or overgrown foliage." }
};
var resources = new
{
Water = new ActionResource { Name = "Water", Notes = "Room-temperature filtered water." },
Fertilizer = new ActionResource { Name = "Liquid Fertilizer", Notes = "Balanced houseplant concentrate." },
PottingMix = new ActionResource { Name = "Potting Mix", Notes = "General indoor plant substrate." },
OrchidBark = new ActionResource { Name = "Orchid Bark", Notes = "Chunky aeration component." },
Perlite = new ActionResource { Name = "Perlite", Notes = "Lightweight aeration component." },
NeemOil = new ActionResource { Name = "Neem Oil", Notes = "Pest treatment concentrate." },
Shears = new ActionResource { Name = "Clean Shears", Notes = "Sterilized cutting tool." }
};
var nutrientMix = new Recipe
{
Name = "Gentle Nutrient Mix",
MeasurementMode = "bakers_percent",
OutputResource = new ActionResource
{
Name = "Gentle Nutrient Mix",
Notes = "Seed recipe output used by fertilizing activities."
},
Notes = "Water is the 100% base; fertilizer is measured against that base.",
Components =
[
new RecipeComponent
{
ActionResource = resources.Water,
Quantity = 100,
Unit = "%",
Notes = "Base",
SortOrder = 0
},
new RecipeComponent
{
ActionResource = resources.Fertilizer,
Quantity = 5,
Unit = "%",
Notes = "Light feeding strength",
SortOrder = 1
}
]
};
var waterActivity = new CareActivity
{
Name = "Water",
Notes = "Routine watering based on substrate dryness.",
Actions =
[
new CareActivityAction
{
CareAction = actions.Water,
SortOrder = 0,
Resources =
[
new CareActivityActionResource
{
ActionResource = resources.Water,
Quantity = 500,
Unit = "ml",
Notes = "Adjust by pot size."
}
]
}
]
};
var fertilizeActivity = new CareActivity
{
Name = "Fertilize",
Notes = "Light feeding during active growth.",
Actions =
[
new CareActivityAction
{
CareAction = actions.Fertilize,
SortOrder = 0,
Resources =
[
new CareActivityActionResource
{
ActionResource = nutrientMix.OutputResource,
Quantity = 250,
Unit = "ml",
Notes = "Use after watering if soil is very dry."
}
]
}
]
};
var inspectActivity = new CareActivity
{
Name = "Pest Check",
Notes = "Inspect leaves, stems, and soil surface.",
Actions =
[
new CareActivityAction
{
CareAction = actions.Inspect,
SortOrder = 0,
Resources =
[
new CareActivityActionResource
{
ActionResource = resources.NeemOil,
Quantity = 0,
Unit = "ml",
Notes = "Only use if pests are found."
}
]
}
]
};
var repotActivity = new CareActivity
{
Name = "Repot",
Notes = "Refresh substrate and inspect roots.",
Actions =
[
new CareActivityAction
{
CareAction = actions.Repot,
SortOrder = 0,
Resources =
[
new CareActivityActionResource { ActionResource = resources.PottingMix, Quantity = 1, Unit = "L" },
new CareActivityActionResource { ActionResource = resources.OrchidBark, Quantity = 0.5m, Unit = "L" },
new CareActivityActionResource { ActionResource = resources.Perlite, Quantity = 0.5m, Unit = "L" }
]
}
]
};
var pruneActivity = new CareActivity
{
Name = "Prune",
Notes = "Remove damaged foliage and shape growth.",
Actions =
[
new CareActivityAction
{
CareAction = actions.Prune,
SortOrder = 0,
Resources =
[
new CareActivityActionResource { ActionResource = resources.Shears, Quantity = 1, Unit = "tool" }
]
}
]
};
var flags = new
{
Attention = new PlantFlagDefinition { Name = "Attention", Color = "#FFAB00" },
Recovering = new PlantFlagDefinition { Name = "Recovering", Color = "#36B37E" },
Quarantine = new PlantFlagDefinition { Name = "Quarantine", Color = "#FF5630" },
Wishlist = new PlantFlagDefinition { Name = "Wishlist", Color = "#6554C0" }
};
var plants = new
{
Monstera = new Plant
{
Nickname = "Elara",
Birthday = today.AddDays(-420),
Taxon = taxa.Monstera,
Location = locations.LivingRoom
},
Ficus = new Plant
{
Nickname = "Darrow",
Birthday = today.AddDays(-260),
Taxon = taxa.Ficus,
Location = locations.LivingRoom
},
Kris = new Plant
{
Nickname = "Tamsin",
Birthday = today.AddDays(-120),
Taxon = taxa.Alocasia,
Location = locations.Quarantine
},
Peperomia = new Plant
{
Nickname = "Rowan",
Birthday = today.AddDays(-95),
Taxon = taxa.Peperomia,
Location = locations.Office
},
Pothos = new Plant
{
Nickname = "Juno",
Birthday = today.AddDays(-700),
Taxon = taxa.Pothos,
Location = locations.Kitchen
},
Orchid = new Plant
{
Nickname = "Lyric",
Birthday = today.AddDays(-35),
Taxon = taxa.Orchid,
Location = locations.Office
}
};
var groups = new
{
LivingRoom = new PlantGroup
{
Name = "Living Room Group",
Notes = "Larger statement plants.",
Memberships =
[
new PlantGroupMembership { Plant = plants.Monstera },
new PlantGroupMembership { Plant = plants.Ficus }
]
},
Humidity = new PlantGroup
{
Name = "Humidity Lovers",
Notes = "Plants that prefer steadier humidity.",
Memberships =
[
new PlantGroupMembership { Plant = plants.Kris },
new PlantGroupMembership { Plant = plants.Orchid },
new PlantGroupMembership { Plant = plants.Peperomia }
]
},
EasyCare = new PlantGroup
{
Name = "Easy Care",
Notes = "Reliable low-maintenance plants.",
Memberships =
[
new PlantGroupMembership { Plant = plants.Pothos },
new PlantGroupMembership { Plant = plants.Peperomia }
]
}
};
plants.Ficus.Flags.Add(new PlantFlag
{
Definition = flags.Attention,
StartedOn = today.AddDays(-3),
Notes = "Watch for leaf drop after relocation."
});
plants.Kris.Flags.Add(new PlantFlag
{
Definition = flags.Quarantine,
StartedOn = today.AddDays(-8),
Notes = "New arrival. Inspect before moving near other plants."
});
plants.Orchid.Flags.Add(new PlantFlag
{
Definition = flags.Recovering,
StartedOn = today.AddDays(-15),
ResolvedOn = today.AddDays(-2),
Notes = "Recovered after bloom spike trim."
});
waterActivity.PlantCareSchedules =
[
new PlantCareSchedule
{
CareAction = actions.Water,
EveryDays = 7,
ScheduledFor = today.AddDays(-7),
RecurrenceMode = "weekly",
RepeatEvery = 1,
RepeatUnit = "week",
EndsMode = "never",
Assignments =
[
new PlantCareScheduleAssignment { Plant = plants.Monstera },
new PlantCareScheduleAssignment { Plant = plants.Ficus },
new PlantCareScheduleAssignment { Plant = plants.Pothos }
]
},
new PlantCareSchedule
{
CareAction = actions.Water,
EveryDays = 4,
ScheduledFor = today.AddDays(-4),
RecurrenceMode = "custom",
RepeatEvery = 4,
RepeatUnit = "day",
EndsMode = "never",
Assignments =
[
new PlantCareScheduleAssignment { Plant = plants.Kris },
new PlantCareScheduleAssignment { Plant = plants.Peperomia },
new PlantCareScheduleAssignment { Plant = plants.Orchid }
]
}
];
fertilizeActivity.PlantCareSchedules =
[
new PlantCareSchedule
{
CareAction = actions.Fertilize,
EveryDays = 30,
ScheduledFor = today.AddDays(5),
RecurrenceMode = "monthly",
RepeatEvery = 1,
RepeatUnit = "month",
EndsMode = "never",
Assignments =
[
new PlantCareScheduleAssignment { Plant = plants.Monstera },
new PlantCareScheduleAssignment { Plant = plants.Pothos },
new PlantCareScheduleAssignment { Plant = plants.Peperomia }
]
}
];
inspectActivity.PlantCareSchedules =
[
new PlantCareSchedule
{
CareAction = actions.Inspect,
EveryDays = 14,
ScheduledFor = today.AddDays(-2),
RecurrenceMode = "weekly",
RepeatEvery = 1,
RepeatUnit = "week",
EndsMode = "after",
EndsAfterOccurrences = 8,
Assignments =
[
new PlantCareScheduleAssignment { Plant = plants.Kris },
new PlantCareScheduleAssignment { Plant = plants.Ficus }
]
}
];
repotActivity.PlantCareSchedules =
[
new PlantCareSchedule
{
CareAction = actions.Repot,
EveryDays = 365,
ScheduledFor = today.AddDays(45),
RecurrenceMode = "none",
RepeatEvery = 1,
RepeatUnit = "week",
EndsMode = "after",
EndsAfterOccurrences = 1,
Assignments =
[
new PlantCareScheduleAssignment { Plant = plants.Ficus }
]
}
];
plants.Monstera.ActionLogs.Add(new ActionLog
{
CareAction = actions.Water,
CareActivity = waterActivity,
ActionNameSnapshot = waterActivity.Name,
PerformedOn = today.AddDays(-7),
Notes = "Thorough soak; pot drained well.",
Resources =
[
new ActionLogResource { ActionResource = resources.Water, Quantity = 650, Unit = "ml" }
]
});
plants.Pothos.ActionLogs.Add(new ActionLog
{
CareAction = actions.Water,
CareActivity = waterActivity,
ActionNameSnapshot = waterActivity.Name,
PerformedOn = today.AddDays(-10),
Notes = "Let dry longer next round.",
Resources =
[
new ActionLogResource { ActionResource = resources.Water, Quantity = 400, Unit = "ml" }
]
});
plants.Peperomia.ActionLogs.Add(new ActionLog
{
CareAction = actions.Inspect,
CareActivity = inspectActivity,
ActionNameSnapshot = inspectActivity.Name,
PerformedOn = today.AddDays(-1),
Notes = "No pests observed.",
Resources = []
});
plants.Monstera.ActionLogs.Add(new ActionLog
{
CareAction = actions.Fertilize,
CareActivity = fertilizeActivity,
ActionNameSnapshot = fertilizeActivity.Name,
PerformedOn = today.AddDays(-28),
Notes = "Light feed during new leaf growth.",
Resources =
[
new ActionLogResource { ActionResource = nutrientMix.OutputResource, Quantity = 250, Unit = "ml" }
]
});
db.PlantTaxa.AddRange(taxa.Monstera, taxa.Ficus, taxa.Alocasia, taxa.Peperomia, taxa.Pothos, taxa.Orchid);
db.PlantLocations.AddRange(locations.LivingRoom, locations.Office, locations.Kitchen, locations.Quarantine);
db.CareActions.AddRange(actions.Water, actions.Fertilize, actions.Inspect, actions.Repot, actions.Prune);
db.ActionResources.AddRange(resources.Water, resources.Fertilizer, resources.PottingMix, resources.OrchidBark, resources.Perlite, resources.NeemOil, resources.Shears);
db.Recipes.Add(nutrientMix);
db.CareActivities.AddRange(waterActivity, fertilizeActivity, inspectActivity, repotActivity, pruneActivity);
db.PlantFlagDefinitions.AddRange(flags.Attention, flags.Recovering, flags.Quarantine, flags.Wishlist);
db.Plants.AddRange(plants.Monstera, plants.Ficus, plants.Kris, plants.Peperomia, plants.Pothos, plants.Orchid);
db.PlantGroups.AddRange(groups.LivingRoom, groups.Humidity, groups.EasyCare);
await db.SaveChangesAsync();
}
}
}
@@ -76,6 +76,80 @@ namespace plant_manager.Endpoints
return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log)); return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log));
}); });
app.MapPost("/api/action-logs/bulk", async (BulkCompleteCareTasksRequest request, ApplicationDbContext db) =>
{
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return Results.BadRequest(new { error = "At least one plant is required." });
}
var plants = await db.Plants
.Where(plant => plantIds.Contains(plant.Id))
.OrderBy(plant => plant.Nickname)
.ToListAsync();
if (plants.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "One or more plants were not found." });
}
var activity = await db.CareActivities
.Include(item => item.Actions)
.ThenInclude(action => action.CareAction)
.Include(item => item.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
var primaryAction = activity.PrimaryAction();
if (primaryAction is null)
{
return Results.BadRequest(new { error = "Care activity has no configured actions." });
}
var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow);
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db);
if (resourceError is not null)
{
return Results.BadRequest(new { error = resourceError });
}
var logs = plants
.Select(plant => new ActionLog
{
PlantId = plant.Id,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
CareAction = primaryAction,
CareActivity = activity,
ActionNameSnapshot = activity.Name,
Notes = notes,
PerformedOn = performedOn,
Resources = resources
.Select(resource => new ActionLogResource
{
ActionResourceId = resource.ActionResourceId,
Quantity = resource.Quantity,
Unit = resource.Unit
})
.ToList()
})
.ToList();
db.ActionLogs.AddRange(logs);
await db.SaveChangesAsync();
return Results.Ok(new { completed = logs.Count });
});
app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) => app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) =>
{ {
var log = await db.ActionLogs var log = await db.ActionLogs
@@ -16,6 +16,9 @@ namespace plant_manager.Endpoints
.Include(activity => activity.Actions) .Include(activity => activity.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.ThenInclude(resource => resource.ProducedByRecipe)
.ThenInclude(recipe => recipe!.Components)
.ThenInclude(component => component.ActionResource)
.OrderBy(activity => activity.Name) .OrderBy(activity => activity.Name)
.Select(activity => CareActivityDto.FromCareActivity(activity)) .Select(activity => CareActivityDto.FromCareActivity(activity))
.ToListAsync(); .ToListAsync();
@@ -59,6 +62,9 @@ namespace plant_manager.Endpoints
.Include(item => item.Actions) .Include(item => item.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.ThenInclude(resource => resource.ProducedByRecipe)
.ThenInclude(recipe => recipe!.Components)
.ThenInclude(component => component.ActionResource)
.FirstOrDefaultAsync(item => item.Id == id); .FirstOrDefaultAsync(item => item.Id == id);
if (activity is null) if (activity is null)
{ {
+186 -45
View File
@@ -11,41 +11,32 @@ namespace plant_manager.Endpoints
async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db) async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db)
{ {
var today = DateOnly.FromDateTime(DateTime.UtcNow); var today = DateOnly.FromDateTime(DateTime.UtcNow);
var schedules = await db.PlantCareSchedules var assignments = await db.PlantCareScheduleAssignments
.Include(schedule => schedule.Plant) .Include(assignment => assignment.Plant)
.Include(schedule => schedule.CareAction) .ThenInclude(plant => plant.CareDismissals)
.Include(schedule => schedule.CareActivity) .Include(assignment => assignment.Plant)
.ThenInclude(plant => plant.ActionLogs)
.Include(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction)
.Include(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction) .ThenInclude(action => action.CareAction)
.Include(schedule => schedule.CareActivity) .Include(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.OrderBy(schedule => schedule.Plant.Nickname) .OrderBy(assignment => assignment.Plant.Nickname)
.ThenBy(schedule => schedule.CareActivity.Name) .ThenBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
.ToListAsync(); .ToListAsync();
var latestLogs = await db.ActionLogs var (latestLogLookup, completedLookup) = await GetCareProgress(db);
.GroupBy(log => new { log.PlantId, log.CareActivityId })
.Select(group => new
{
group.Key.PlantId,
group.Key.CareActivityId,
LastPerformedOn = group.Max(log => log.PerformedOn),
CompletedOccurrences = group.Count()
})
.ToListAsync();
var latestLogLookup = latestLogs.ToDictionary(
log => (log.PlantId, log.CareActivityId),
log => (DateOnly?)log.LastPerformedOn);
var completedLookup = latestLogs.ToDictionary(
log => (log.PlantId, log.CareActivityId),
log => log.CompletedOccurrences);
var tasks = schedules var tasks = assignments
.Select(schedule => CareTaskDto.FromSchedule( .Select(assignment => CareTaskDto.FromAssignment(
schedule, assignment,
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)), latestLogLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
completedLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)), completedLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
today)) today))
.Where(task => task.Status is "due" or "soon") .Where(task => task.Status is "due" or "soon")
.ToList(); .ToList();
@@ -88,14 +79,15 @@ namespace plant_manager.Endpoints
} }
var today = DateOnly.FromDateTime(DateTime.UtcNow); var today = DateOnly.FromDateTime(DateTime.UtcNow);
var schedules = await db.PlantCareSchedules var assignments = await db.PlantCareScheduleAssignments
.Include(schedule => schedule.Plant) .Include(assignment => assignment.Plant)
.Where(schedule => .Include(assignment => assignment.PlantCareSchedule)
schedule.CareActivityId == activity.Id .Where(assignment =>
&& plantIds.Contains(schedule.PlantId)) assignment.PlantCareSchedule.CareActivityId == activity.Id
&& plantIds.Contains(assignment.PlantId))
.ToListAsync(); .ToListAsync();
var schedulePlantIds = schedules var schedulePlantIds = assignments
.Select(schedule => schedule.PlantId) .Select(assignment => assignment.PlantId)
.ToHashSet(); .ToHashSet();
if (schedulePlantIds.Count != plantIds.Count) if (schedulePlantIds.Count != plantIds.Count)
{ {
@@ -118,15 +110,33 @@ namespace plant_manager.Endpoints
var completedLookup = latestLogs.ToDictionary( var completedLookup = latestLogs.ToDictionary(
log => log.PlantId, log => log.PlantId,
log => log.CompletedOccurrences); log => log.CompletedOccurrences);
var duePlantIds = schedules var dismissals = await db.CareDismissals
.Where(schedule => .Where(dismissal => dismissal.CareActivityId == activity.Id && plantIds.Contains(dismissal.PlantId))
.GroupBy(dismissal => dismissal.PlantId)
.Select(group => new
{
PlantId = group.Key,
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
DismissedOccurrences = group.Count()
})
.ToListAsync();
foreach (var dismissal in dismissals)
{
latestLogLookup[dismissal.PlantId] = MaxDate(
latestLogLookup.GetValueOrDefault(dismissal.PlantId),
dismissal.LastDismissedOn);
completedLookup[dismissal.PlantId] = completedLookup.GetValueOrDefault(dismissal.PlantId)
+ dismissal.DismissedOccurrences;
}
var duePlantIds = assignments
.Where(assignment =>
PlantCareFormatter.GetStatus( PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate( PlantCareFormatter.GetNextCareDate(
schedule, assignment.PlantCareSchedule,
latestLogLookup.GetValueOrDefault(schedule.PlantId), latestLogLookup.GetValueOrDefault(assignment.PlantId),
completedLookup.GetValueOrDefault(schedule.PlantId)), completedLookup.GetValueOrDefault(assignment.PlantId)),
today) == "due") today) == "due")
.Select(schedule => schedule.PlantId) .Select(assignment => assignment.PlantId)
.ToHashSet(); .ToHashSet();
if (duePlantIds.Count != plantIds.Count) if (duePlantIds.Count != plantIds.Count)
@@ -166,11 +176,11 @@ namespace plant_manager.Endpoints
var performedOn = request.PerformedOn ?? today; var performedOn = request.PerformedOn ?? today;
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
var logs = schedules var logs = assignments
.OrderBy(schedule => schedule.Plant.Nickname) .OrderBy(assignment => assignment.Plant.Nickname)
.Select(schedule => new ActionLog .Select(assignment => new ActionLog
{ {
PlantId = schedule.PlantId, PlantId = assignment.PlantId,
CareActionId = primaryAction.Id, CareActionId = primaryAction.Id,
CareActivityId = activity.Id, CareActivityId = activity.Id,
ActionNameSnapshot = activity.Name, ActionNameSnapshot = activity.Name,
@@ -192,8 +202,139 @@ namespace plant_manager.Endpoints
return Results.Ok(new { completed = logs.Count }); return Results.Ok(new { completed = logs.Count });
}); });
app.MapPost("/api/care-tasks/dismiss-bulk", async (
DismissCareTasksRequest request,
ApplicationDbContext db) =>
{
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return Results.BadRequest(new { error = "At least one plant is required." });
}
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var assignments = await db.PlantCareScheduleAssignments
.Include(assignment => assignment.Plant)
.Include(assignment => assignment.PlantCareSchedule)
.Where(assignment =>
assignment.PlantCareSchedule.CareActivityId == activity.Id
&& plantIds.Contains(assignment.PlantId))
.ToListAsync();
var schedulePlantIds = assignments
.Select(assignment => assignment.PlantId)
.ToHashSet();
if (schedulePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
}
var (latestLookup, completedLookup) = await GetCareProgress(db, activity.Id, plantIds);
var duePlantIds = assignments
.Where(assignment =>
PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate(
assignment.PlantCareSchedule,
latestLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
today) == "due")
.Select(assignment => assignment.PlantId)
.ToHashSet();
if (duePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "Only due care tasks can be dismissed." });
}
var dismissedOn = request.DismissedOn ?? today;
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
var dismissals = assignments
.OrderBy(assignment => assignment.Plant.Nickname)
.Select(assignment => new CareDismissal
{
PlantId = assignment.PlantId,
CareActivityId = activity.Id,
DismissedOn = dismissedOn,
Notes = notes
})
.ToList();
db.CareDismissals.AddRange(dismissals);
await db.SaveChangesAsync();
return Results.Ok(new { dismissed = dismissals.Count });
});
} }
private static async Task<(
Dictionary<(int PlantId, int CareActivityId), DateOnly?> LatestLookup,
Dictionary<(int PlantId, int CareActivityId), int> CompletedLookup)> GetCareProgress(
ApplicationDbContext db,
int? careActivityId = null,
IReadOnlyList<int>? plantIds = null)
{
var logsQuery = db.ActionLogs.AsQueryable();
var dismissalsQuery = db.CareDismissals.AsQueryable();
if (careActivityId is not null)
{
logsQuery = logsQuery.Where(log => log.CareActivityId == careActivityId);
dismissalsQuery = dismissalsQuery.Where(dismissal => dismissal.CareActivityId == careActivityId);
}
if (plantIds is not null)
{
logsQuery = logsQuery.Where(log => plantIds.Contains(log.PlantId));
dismissalsQuery = dismissalsQuery.Where(dismissal => plantIds.Contains(dismissal.PlantId));
}
var latestLogs = await logsQuery
.GroupBy(log => new { log.PlantId, log.CareActivityId })
.Select(group => new
{
group.Key.PlantId,
group.Key.CareActivityId,
LastPerformedOn = group.Max(log => log.PerformedOn),
CompletedOccurrences = group.Count()
})
.ToListAsync();
var latestLookup = latestLogs.ToDictionary(
log => (log.PlantId, log.CareActivityId),
log => (DateOnly?)log.LastPerformedOn);
var completedLookup = latestLogs.ToDictionary(
log => (log.PlantId, log.CareActivityId),
log => log.CompletedOccurrences);
var latestDismissals = await dismissalsQuery
.GroupBy(dismissal => new { dismissal.PlantId, dismissal.CareActivityId })
.Select(group => new
{
group.Key.PlantId,
group.Key.CareActivityId,
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
DismissedOccurrences = group.Count()
})
.ToListAsync();
foreach (var dismissal in latestDismissals)
{
var key = (dismissal.PlantId, dismissal.CareActivityId);
latestLookup[key] = MaxDate(latestLookup.GetValueOrDefault(key), dismissal.LastDismissedOn);
completedLookup[key] = completedLookup.GetValueOrDefault(key) + dismissal.DismissedOccurrences;
}
return (latestLookup, completedLookup);
}
private static DateOnly MaxDate(DateOnly? left, DateOnly right) =>
left is null || right > left ? right : left.Value;
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) => private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
activity.Actions activity.Actions
.SelectMany(action => action.Resources) .SelectMany(action => action.Resources)
+15 -9
View File
@@ -43,7 +43,8 @@ namespace plant_manager.Endpoints
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships) .Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup) .ThenInclude(membership => membership.PlantGroup)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.Include(plant => plant.ActionLogs) .Include(plant => plant.ActionLogs)
.AsSplitQuery() .AsSplitQuery()
@@ -65,7 +66,7 @@ namespace plant_manager.Endpoints
.Where(flag => flag.ResolvedOn == null) .Where(flag => flag.ResolvedOn == null)
.OrderBy(flag => flag.Definition.Name) .OrderBy(flag => flag.Definition.Name)
.Select(flag => flag.Definition.Name)), .Select(flag => flag.Definition.Name)),
plant.CareSchedules.Count, plant.CareScheduleAssignments.Count,
plant.ActionLogs.Count plant.ActionLogs.Count
}) })
.ToList(); .ToList();
@@ -87,18 +88,23 @@ namespace plant_manager.Endpoints
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db) private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
{ {
var schedules = await db.PlantCareSchedules var schedules = await db.PlantCareSchedules
.Include(schedule => schedule.Plant)
.Include(schedule => schedule.CareActivity) .Include(schedule => schedule.CareActivity)
.Include(schedule => schedule.CareAction) .Include(schedule => schedule.CareAction)
.OrderBy(schedule => schedule.Plant.Nickname) .Include(schedule => schedule.Assignments)
.ThenBy(schedule => schedule.CareActivity.Name) .ThenInclude(assignment => assignment.Plant)
.OrderBy(schedule => schedule.CareActivity.Name)
.ThenBy(schedule => schedule.Id)
.ToListAsync(); .ToListAsync();
var rows = schedules var rows = schedules
.Select(schedule => new object?[] .Select(schedule => new object?[]
{ {
schedule.Id, schedule.Id,
schedule.PlantId, string.Join(", ", schedule.Assignments
schedule.Plant.Nickname, .OrderBy(assignment => assignment.Plant.Nickname)
.Select(assignment => assignment.PlantId)),
string.Join(", ", schedule.Assignments
.OrderBy(assignment => assignment.Plant.Nickname)
.Select(assignment => assignment.Plant.Nickname)),
schedule.CareActivity.Name, schedule.CareActivity.Name,
schedule.CareAction.Name, schedule.CareAction.Name,
schedule.EveryDays, schedule.EveryDays,
@@ -115,8 +121,8 @@ namespace plant_manager.Endpoints
AddSheet(workbook, "Care Schedules", [ AddSheet(workbook, "Care Schedules", [
"ID", "ID",
"Plant ID", "Plant IDs",
"Plant", "Plants",
"Activity", "Activity",
"Primary Action", "Primary Action",
"Every Days", "Every Days",
+11 -71
View File
@@ -69,7 +69,7 @@ namespace plant_manager.Endpoints
{ {
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag"); await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
} }
await ImportTaxa(workbook, db, context, apply); ImportTaxa(workbook, context);
if (context.Sheets.Count == 0) if (context.Sheets.Count == 0)
{ {
@@ -343,87 +343,27 @@ namespace plant_manager.Endpoints
AddSummary(context, summary); AddSummary(context, summary);
} }
private static async Task ImportTaxa( private static void ImportTaxa(
XLWorkbook workbook, XLWorkbook workbook,
ApplicationDbContext db, ImportContext context)
ImportContext context,
bool apply)
{ {
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet)) if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
{ {
return; return;
} }
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]); var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: []);
var existing = await db.PlantTaxa.ToListAsync();
var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var summary = new MutableSummary(TaxaSheet); var summary = new MutableSummary(TaxaSheet);
foreach (var row in rows) foreach (var row in rows)
{ {
var name = Required(row, "Name", context); context.Issues.Add(new CatalogImportIssue(
var genus = Required(row, "Genus", context); TaxaSheet,
var species = Required(row, "Species", context); row.Row.RowNumber(),
if (name is null || genus is null || species is null) "Taxa",
{ "Taxa must be imported from GBIF search results, not spreadsheet rows.",
summary.Skips++; "error"));
continue; summary.Skips++;
}
var key = Key(name);
if (!seen.Add(key))
{
Duplicate(row, "Name", context);
summary.Skips++;
continue;
}
var cultivar = Optional(row, "Cultivar");
var variety = Optional(row, "Variety");
var authority = Optional(row, "Authority");
var family = Optional(row, "Family");
var commonName = Optional(row, "Common Name", "CommonName");
var externalSource = Optional(row, "External Source", "Source");
var externalId = Optional(row, "External ID", "External Id", "GBIF ID");
if (byName.TryGetValue(key, out var taxon))
{
summary.Updates++;
if (apply)
{
taxon.Name = name;
taxon.Genus = genus;
taxon.Species = species;
taxon.Cultivar = cultivar;
taxon.Variety = variety;
taxon.Authority = authority;
taxon.Family = family;
taxon.CommonName = commonName;
taxon.ExternalSource = externalSource;
taxon.ExternalId = externalId;
}
}
else
{
summary.Creates++;
if (apply)
{
db.PlantTaxa.Add(new PlantTaxon
{
Name = name,
Genus = genus,
Species = species,
Cultivar = cultivar,
Variety = variety,
Authority = authority,
Family = family,
CommonName = commonName,
ExternalSource = externalSource,
ExternalId = externalId
});
}
}
} }
AddSummary(context, summary); AddSummary(context, summary);
@@ -8,107 +8,170 @@ namespace plant_manager.Endpoints
{ {
public static void MapPlantCareScheduleEndpoints(this WebApplication app) public static void MapPlantCareScheduleEndpoints(this WebApplication app)
{ {
app.MapPost("/api/plant-care-schedules/bulk", async ( app.MapGet("/api/plant-care-schedules", async (ApplicationDbContext db) =>
BulkSavePlantCareScheduleRequest request,
ApplicationDbContext db) =>
{ {
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return Results.BadRequest(new { error = "At least one plant is required." });
}
var activity = await db.CareActivities
.Include(item => item.Actions)
.ThenInclude(action => action.CareAction)
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
var primaryAction = activity.PrimaryAction();
if (primaryAction is null)
{
return Results.BadRequest(new { error = "Care activity has no configured actions." });
}
var plants = await db.Plants
.Include(plant => plant.CareSchedules)
.Where(plant => plantIds.Contains(plant.Id))
.ToListAsync();
if (plants.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "One or more plants were not found." });
}
var recurrence = NormalizeRecurrence(
request.RecurrenceMode,
request.RepeatEvery,
request.RepeatUnit,
request.RepeatOnDays,
request.EndsMode,
request.EndsOn,
request.EndsAfterOccurrences,
request.ScheduledFor,
request.EveryDays);
foreach (var plant in plants)
{
var schedule = plant.CareSchedules
.FirstOrDefault(item => item.CareActivityId == activity.Id);
if (schedule is null)
{
schedule = new PlantCareSchedule
{
PlantId = plant.Id,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id
};
plant.CareSchedules.Add(schedule);
}
schedule.CareActionId = primaryAction.Id;
schedule.CareActivityId = activity.Id;
ApplyRecurrence(schedule, recurrence);
}
await db.SaveChangesAsync();
return Results.Ok(new { updated = plants.Count });
});
app.MapPost("/api/plant-care-schedules/bulk-remove", async (
BulkSavePlantCareScheduleRequest request,
ApplicationDbContext db) =>
{
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return Results.BadRequest(new { error = "At least one plant is required." });
}
if (request.CareActivityId <= 0)
{
return Results.BadRequest(new { error = "Care activity is required." });
}
var schedules = await db.PlantCareSchedules var schedules = await db.PlantCareSchedules
.Where(schedule => .Include(schedule => schedule.CareActivity)
schedule.CareActivityId == request.CareActivityId .Include(schedule => schedule.CareAction)
&& plantIds.Contains(schedule.PlantId)) .Include(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.OrderBy(schedule => schedule.CareActivity.Name)
.ThenBy(schedule => schedule.Id)
.AsSplitQuery()
.ToListAsync(); .ToListAsync();
db.PlantCareSchedules.RemoveRange(schedules); return Results.Ok(schedules.Select(PlantCareScheduleRuleDto.FromSchedule));
});
app.MapPost("/api/plant-care-schedules", async (
BulkSavePlantCareScheduleRequest request,
ApplicationDbContext db) =>
{
var context = await ValidateScheduleRequest(request, db);
if (context.Error is not null)
{
return Results.BadRequest(new { error = context.Error });
}
var schedule = new PlantCareSchedule
{
CareActivityId = context.Activity!.Id,
CareActionId = context.PrimaryAction!.Id,
CareActivity = context.Activity,
CareAction = context.PrimaryAction
};
ApplyRecurrence(schedule, context.Recurrence!);
schedule.Assignments = context.PlantIds
.Select(plantId => new PlantCareScheduleAssignment { PlantId = plantId })
.ToList();
db.PlantCareSchedules.Add(schedule);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new { removed = schedules.Count }); var saved = await LoadSchedule(schedule.Id, db);
return Results.Created($"/api/plant-care-schedules/{schedule.Id}", PlantCareScheduleRuleDto.FromSchedule(saved!));
}); });
app.MapPut("/api/plant-care-schedules/{id:int}", async (
int id,
BulkSavePlantCareScheduleRequest request,
ApplicationDbContext db) =>
{
var schedule = await db.PlantCareSchedules
.Include(item => item.Assignments)
.FirstOrDefaultAsync(item => item.Id == id);
if (schedule is null)
{
return Results.NotFound();
}
var context = await ValidateScheduleRequest(request, db);
if (context.Error is not null)
{
return Results.BadRequest(new { error = context.Error });
}
schedule.CareActivityId = context.Activity!.Id;
schedule.CareActionId = context.PrimaryAction!.Id;
ApplyRecurrence(schedule, context.Recurrence!);
var nextPlantIds = context.PlantIds.ToHashSet();
var assignmentsToRemove = schedule.Assignments
.Where(assignment => !nextPlantIds.Contains(assignment.PlantId))
.ToList();
db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
var existingPlantIds = schedule.Assignments
.Select(assignment => assignment.PlantId)
.ToHashSet();
foreach (var plantId in context.PlantIds.Where(plantId => !existingPlantIds.Contains(plantId)))
{
schedule.Assignments.Add(new PlantCareScheduleAssignment
{
PlantCareScheduleId = schedule.Id,
PlantId = plantId
});
}
await db.SaveChangesAsync();
var saved = await LoadSchedule(schedule.Id, db);
return Results.Ok(PlantCareScheduleRuleDto.FromSchedule(saved!));
});
app.MapDelete("/api/plant-care-schedules/{id:int}", async (int id, ApplicationDbContext db) =>
{
var schedule = await db.PlantCareSchedules.FindAsync(id);
if (schedule is null)
{
return Results.NotFound();
}
db.PlantCareSchedules.Remove(schedule);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
private static async Task<PlantCareSchedule?> LoadSchedule(int id, ApplicationDbContext db) =>
await db.PlantCareSchedules
.Include(schedule => schedule.CareActivity)
.Include(schedule => schedule.CareAction)
.Include(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.AsSplitQuery()
.FirstOrDefaultAsync(schedule => schedule.Id == id);
private static async Task<ValidatedScheduleRequest> ValidateScheduleRequest(
BulkSavePlantCareScheduleRequest request,
ApplicationDbContext db)
{
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return ValidatedScheduleRequest.Invalid("At least one plant is required.");
}
var activity = await db.CareActivities
.Include(item => item.Actions)
.ThenInclude(action => action.CareAction)
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
if (activity is null)
{
return ValidatedScheduleRequest.Invalid("Care activity was not found.");
}
var primaryAction = activity.PrimaryAction();
if (primaryAction is null)
{
return ValidatedScheduleRequest.Invalid("Care activity has no configured actions.");
}
var existingPlantIds = await db.Plants
.Where(plant => plantIds.Contains(plant.Id))
.Select(plant => plant.Id)
.ToListAsync();
if (existingPlantIds.Count != plantIds.Count)
{
return ValidatedScheduleRequest.Invalid("One or more plants were not found.");
}
var recurrence = NormalizeRecurrence(
request.RecurrenceMode,
request.RepeatEvery,
request.RepeatUnit,
request.RepeatOnDays,
request.EndsMode,
request.EndsOn,
request.EndsAfterOccurrences,
request.ScheduledFor,
request.EveryDays);
return ValidatedScheduleRequest.Valid(plantIds, activity, primaryAction, recurrence);
} }
internal static ScheduleRecurrence NormalizeRecurrence( internal static ScheduleRecurrence NormalizeRecurrence(
@@ -139,7 +202,7 @@ namespace plant_manager.Endpoints
}; };
var normalizedEndsMode = mode == "none" var normalizedEndsMode = mode == "none"
? "after" ? "after"
: NormalizeOption(endsMode, new HashSet<string> { "on", "after" }, "after"); : NormalizeOption(endsMode, new HashSet<string> { "never", "on", "after" }, "never");
var normalizedEveryDays = mode switch var normalizedEveryDays = mode switch
{ {
"none" => Math.Clamp(everyDays ?? 7, 1, 365), "none" => Math.Clamp(everyDays ?? 7, 1, 365),
@@ -199,5 +262,23 @@ namespace plant_manager.Endpoints
string EndsMode, string EndsMode,
DateOnly? EndsOn, DateOnly? EndsOn,
int? EndsAfterOccurrences); int? EndsAfterOccurrences);
private sealed record ValidatedScheduleRequest(
IReadOnlyList<int> PlantIds,
CareActivity? Activity,
CareAction? PrimaryAction,
ScheduleRecurrence? Recurrence,
string? Error)
{
public static ValidatedScheduleRequest Invalid(string error) =>
new([], null, null, null, error);
public static ValidatedScheduleRequest Valid(
IReadOnlyList<int> plantIds,
CareActivity activity,
CareAction primaryAction,
ScheduleRecurrence recurrence) =>
new(plantIds, activity, primaryAction, recurrence, null);
}
} }
} }
+85 -18
View File
@@ -13,20 +13,34 @@ namespace plant_manager.Endpoints
var plants = await db.Plants var plants = await db.Plants
.Include(plant => plant.Taxon) .Include(plant => plant.Taxon)
.Include(plant => plant.Location) .Include(plant => plant.Location)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction) .ThenInclude(schedule => schedule.CareAction)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction) .ThenInclude(action => action.CareAction)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.ActionLogs)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(plant => plant.ActionLogs) .Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources) .ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareDismissals)
.Include(plant => plant.Flags) .Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships) .Include(plant => plant.GroupMemberships)
@@ -42,20 +56,34 @@ namespace plant_manager.Endpoints
var plant = await db.Plants var plant = await db.Plants
.Include(item => item.Taxon) .Include(item => item.Taxon)
.Include(item => item.Location) .Include(item => item.Location)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction) .ThenInclude(schedule => schedule.CareAction)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction) .ThenInclude(action => action.CareAction)
.Include(plant => plant.CareSchedules) .Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.ActionLogs)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(plant => plant.ActionLogs) .Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources) .ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareDismissals)
.Include(plant => plant.Flags) .Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships) .Include(plant => plant.GroupMemberships)
@@ -79,6 +107,10 @@ namespace plant_manager.Endpoints
{ {
return Results.BadRequest(new { error = "Taxon was not found." }); return Results.BadRequest(new { error = "Taxon was not found." });
} }
if (taxon is not null && !IsGbifTaxon(taxon))
{
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
}
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId); var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
if (request.LocationId is not null && location is null) if (request.LocationId is not null && location is null)
@@ -122,20 +154,34 @@ namespace plant_manager.Endpoints
var plant = await db.Plants var plant = await db.Plants
.Include(item => item.Taxon) .Include(item => item.Taxon)
.Include(item => item.Location) .Include(item => item.Location)
.Include(item => item.CareSchedules) .Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction) .ThenInclude(schedule => schedule.CareAction)
.Include(item => item.CareSchedules) .Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction) .ThenInclude(action => action.CareAction)
.Include(item => item.CareSchedules) .Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity) .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions) .ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.ActionLogs)
.Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(item => item.ActionLogs) .Include(item => item.ActionLogs)
.ThenInclude(log => log.Resources) .ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(item => item.CareDismissals)
.Include(item => item.Flags) .Include(item => item.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(item => item.GroupMemberships) .Include(item => item.GroupMemberships)
@@ -152,6 +198,10 @@ namespace plant_manager.Endpoints
{ {
return Results.BadRequest(new { error = "Taxon was not found." }); return Results.BadRequest(new { error = "Taxon was not found." });
} }
if (taxon is not null && !IsGbifTaxon(taxon))
{
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
}
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId); var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
if (request.LocationId is not null && location is null) if (request.LocationId is not null && location is null)
@@ -212,6 +262,7 @@ namespace plant_manager.Endpoints
[ [
new SavePlantCareScheduleRequest( new SavePlantCareScheduleRequest(
waterActivity.Id, waterActivity.Id,
null,
7, 7,
null, null,
"weekly", "weekly",
@@ -253,28 +304,40 @@ namespace plant_manager.Endpoints
} }
var requestedActivityIds = activityIds.ToHashSet(); var requestedActivityIds = activityIds.ToHashSet();
var schedulesToRemove = plant.CareSchedules var assignmentsToRemove = plant.CareScheduleAssignments
.Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId)) .Where(assignment => !requestedActivityIds.Contains(assignment.PlantCareSchedule.CareActivityId))
.ToList(); .ToList();
db.PlantCareSchedules.RemoveRange(schedulesToRemove); db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
foreach (var requestedSchedule in normalizedSchedules) foreach (var requestedSchedule in normalizedSchedules)
{ {
var activity = activitiesById[requestedSchedule.CareActivityId]; var activity = activitiesById[requestedSchedule.CareActivityId];
var primaryAction = activity.PrimaryAction()!; var primaryAction = activity.PrimaryAction()!;
var schedule = plant.CareSchedules var assignment = plant.CareScheduleAssignments
.FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId); .FirstOrDefault(item => item.PlantCareSchedule.CareActivityId == requestedSchedule.CareActivityId);
if (schedule is null) var schedule = assignment?.PlantCareSchedule;
if (assignment is null)
{ {
schedule = new PlantCareSchedule schedule = new PlantCareSchedule
{ {
PlantId = plant.Id,
CareActionId = primaryAction.Id, CareActionId = primaryAction.Id,
CareActivityId = activity.Id, CareActivityId = activity.Id,
CareAction = primaryAction, CareAction = primaryAction,
CareActivity = activity CareActivity = activity,
Assignments =
[
new PlantCareScheduleAssignment
{
PlantId = plant.Id,
Plant = plant
}
]
}; };
plant.CareSchedules.Add(schedule); db.PlantCareSchedules.Add(schedule);
}
if (schedule is null)
{
continue;
} }
schedule.CareActionId = primaryAction.Id; schedule.CareActionId = primaryAction.Id;
@@ -295,5 +358,9 @@ namespace plant_manager.Endpoints
return null; return null;
} }
private static bool IsGbifTaxon(PlantTaxon taxon) =>
string.Equals(taxon.ExternalSource, "gbif", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(taxon.ExternalId);
} }
} }
@@ -18,64 +18,6 @@ namespace plant_manager.Endpoints
return Results.Ok(taxa); return Results.Ok(taxa);
}); });
app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) =>
{
var validation = ValidateTaxonRequest(request);
if (validation.Error is not null)
{
return EndpointHelpers.BadRequest(validation.Error);
}
var taxon = new PlantTaxon
{
Name = validation.Name,
Genus = validation.Genus,
Species = validation.Species,
Cultivar = validation.Cultivar,
Variety = validation.Variety,
Authority = validation.Authority,
Family = validation.Family,
CommonName = validation.CommonName,
ExternalSource = validation.ExternalSource,
ExternalId = validation.ExternalId
};
db.PlantTaxa.Add(taxon);
await db.SaveChangesAsync();
return Results.Created($"/api/plant-taxa/{taxon.Id}", PlantTaxonDto.FromTaxon(taxon));
});
app.MapPut("/api/plant-taxa/{id:int}", async (int id, SavePlantTaxonRequest request, ApplicationDbContext db) =>
{
var validation = ValidateTaxonRequest(request);
if (validation.Error is not null)
{
return EndpointHelpers.BadRequest(validation.Error);
}
var taxon = await db.PlantTaxa.FindAsync(id);
if (taxon is null)
{
return Results.NotFound();
}
taxon.Name = validation.Name;
taxon.Genus = validation.Genus;
taxon.Species = validation.Species;
taxon.Cultivar = validation.Cultivar;
taxon.Variety = validation.Variety;
taxon.Authority = validation.Authority;
taxon.Family = validation.Family;
taxon.CommonName = validation.CommonName;
taxon.ExternalSource = validation.ExternalSource;
taxon.ExternalId = validation.ExternalId;
await db.SaveChangesAsync();
return Results.Ok(PlantTaxonDto.FromTaxon(taxon));
});
app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) => app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) =>
{ {
if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase))
@@ -151,40 +93,6 @@ namespace plant_manager.Endpoints
}); });
} }
private static (
string Name,
string Genus,
string Species,
string? Cultivar,
string? Variety,
string? Authority,
string? Family,
string? CommonName,
string? ExternalSource,
string? ExternalId,
string? Error) ValidateTaxonRequest(SavePlantTaxonRequest request)
{
if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Name, genus, and species are required.", out var name, out _)
|| !EndpointHelpers.TryNormalizeRequired(request.Genus, "Name, genus, and species are required.", out var genus, out _)
|| !EndpointHelpers.TryNormalizeRequired(request.Species, "Name, genus, and species are required.", out var species, out _))
{
return (string.Empty, string.Empty, string.Empty, null, null, null, null, null, null, null, "Name, genus, and species are required.");
}
return (
name,
genus,
species,
EndpointHelpers.NormalizeOptional(request.Cultivar),
EndpointHelpers.NormalizeOptional(request.Variety),
EndpointHelpers.NormalizeOptional(request.Authority),
EndpointHelpers.NormalizeOptional(request.Family),
EndpointHelpers.NormalizeOptional(request.CommonName),
EndpointHelpers.NormalizeOptional(request.ExternalSource),
EndpointHelpers.NormalizeOptional(request.ExternalId),
null);
}
private static string? FirstScientificNamePart(string scientificName) => private static string? FirstScientificNamePart(string scientificName) =>
scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+1
View File
@@ -42,6 +42,7 @@ using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate(); db.Database.Migrate();
await db.SeedDevelopmentDataAsync();
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>(); var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
await plantInfoDb.EnsureSearchSchemaAsync(); await plantInfoDb.EnsureSearchSchemaAsync();