-
@@ -254,87 +228,6 @@ export function TaxaView({
) : null}
- {isEditorOpen ? (
-
- ) : null}
-
>
);
}
diff --git a/plant-manager-web/src/domain.ts b/plant-manager-web/src/domain.ts
index 5b62766..51d1f8c 100644
--- a/plant-manager-web/src/domain.ts
+++ b/plant-manager-web/src/domain.ts
@@ -36,9 +36,31 @@ export type PlantCareSchedule = {
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 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 PlantTaxon = {
@@ -183,6 +205,24 @@ export type CareActivityActionResource = {
quantity: number | null;
unit: 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 = {
@@ -255,19 +295,6 @@ export type BulkPlantCareSchedulePayload = {
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 = {
name: string;
notes: string | null;
@@ -341,6 +368,13 @@ export type BulkCompleteCareTasksPayload = {
resources: CareLogResourcePayload[];
};
+export type DismissCareTasksPayload = {
+ careActivityId: number;
+ plantIds: number[];
+ notes: string | null;
+ dismissedOn: string | null;
+};
+
export type CareLogResourcePayload = {
actionResourceId: number;
quantity: number | null;
diff --git a/plant-manager-web/src/form-state.ts b/plant-manager-web/src/form-state.ts
index 4bfd87c..a6c1a09 100644
--- a/plant-manager-web/src/form-state.ts
+++ b/plant-manager-web/src/form-state.ts
@@ -9,7 +9,7 @@ import type {
Recipe,
RecipePayload,
Plant,
- PlantInfoSearchResult,
+ PlantCareScheduleRule,
AssignPlantFlagPayload,
PlantPayload,
PlantFlagDefinition,
@@ -23,7 +23,6 @@ import type {
ScheduleRecurrenceMode,
ScheduleRepeatUnit,
PlantTaxon,
- PlantTaxonPayload,
} from './domain';
export const emptyPlantForm = {
@@ -33,19 +32,6 @@ export const emptyPlantForm = {
locationId: '',
};
-export const emptyTaxonForm = {
- name: '',
- genus: '',
- species: '',
- cultivar: '',
- variety: '',
- authority: '',
- family: '',
- commonName: '',
- externalSource: '',
- externalId: '',
-};
-
export const emptyLocationForm = {
name: '',
notes: '',
@@ -119,14 +105,13 @@ export const emptyBulkScheduleForm = {
repeatEvery: '1',
repeatUnit: 'week',
repeatOnDays: [] as string[],
- endsMode: 'after',
+ endsMode: 'never',
endsOn: '',
endsAfterOccurrences: '12',
plantIds: [] as string[],
};
export type PlantFormState = typeof emptyPlantForm;
-export type TaxonFormState = typeof emptyTaxonForm;
export type LocationFormState = typeof emptyLocationForm;
export type PlantGroupFormState = typeof emptyPlantGroupForm;
export type ActionFormState = typeof emptyActionForm;
@@ -136,7 +121,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm;
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 {
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 {
return {
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) {
const botanical = `${taxon.genus} ${taxon.species}`.trim();
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
diff --git a/plant-manager-web/src/styles.css b/plant-manager-web/src/styles.css
index 1c13f7e..daa2c96 100644
--- a/plant-manager-web/src/styles.css
+++ b/plant-manager-web/src/styles.css
@@ -1013,6 +1013,47 @@ dd {
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 {
padding: 8px 0 8px 12px;
}
@@ -1168,6 +1209,95 @@ dd {
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-grid {
display: grid;
@@ -1499,6 +1629,14 @@ dd {
border-bottom: 1px solid var(--border);
}
+ .care-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-panel-grid {
+ grid-template-columns: 1fr;
+ }
+
.catalog-nav {
display: flex;
width: 100%;
diff --git a/plant-manager-web/src/use-app-actions.ts b/plant-manager-web/src/use-app-actions.ts
index ded8083..b5b9648 100644
--- a/plant-manager-web/src/use-app-actions.ts
+++ b/plant-manager-web/src/use-app-actions.ts
@@ -6,42 +6,42 @@ import {
createActionResource,
createCareAction,
createCareActivity,
+ createActionLogsBulk,
+ createPlantCareSchedule,
createPlant,
createPlantFlag,
createPlantGroup,
createPlantLocation,
- createPlantTaxon,
createRecipe,
deleteActionResource,
deleteCareAction,
deleteCareActivity,
deletePlant,
+ deletePlantCareSchedule,
deletePlantFlag,
deletePlantGroup,
deletePlantLocation,
deletePlantTaxon,
deleteRecipe,
+ dismissCareTasksBulk,
downloadSpreadsheetExport,
importPlantTaxon,
previewCatalogImport,
- removePlantCareSchedulesBulk,
removePlantFlagAssignment,
resolvePlantFlag,
- savePlantCareSchedulesBulk,
searchPlantInfo,
updateActionResource,
updateCareAction,
updateCareActivity,
updatePlant,
+ updatePlantCareSchedule,
updatePlantFlag,
updatePlantGroup,
updatePlantLocation,
- updatePlantTaxon,
updateRecipe,
} 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 {
- emptyBulkScheduleForm,
emptyPlantFlagForm,
toActionPayload,
toActivityPayload,
@@ -53,7 +53,6 @@ import {
toPlantPayload,
toRecipePayload,
toResourcePayload,
- toTaxonPayload,
} from './form-state';
import type { useAppEditors } from './use-app-editors';
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(
queryOverride?: string,
options: { updateQuery?: boolean } = {},
@@ -301,7 +277,7 @@ export function useAppActions({
}
}
- async function removeTaxon(taxon: Parameters
[0]) {
+ async function removeTaxon(taxon: PlantTaxon) {
const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`);
if (!confirmed) {
return;
@@ -310,9 +286,6 @@ export function useAppActions({
setIsSaving(true);
try {
await deletePlantTaxon(taxon.id);
- if (editors.editingTaxonId === taxon.id) {
- editors.cancelEditingTaxon();
- }
await loadTaxaAndPlants();
} catch {
setError('Could not delete the taxon. It may still be used by a plant.');
@@ -618,29 +591,36 @@ export function useAppActions({
setIsSaving(true);
try {
- await savePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
- editors.setBulkScheduleForm(emptyBulkScheduleForm);
+ const payload = toBulkSchedulePayload(editors.bulkScheduleForm);
+ if (editors.editingScheduleId === null) {
+ await createPlantCareSchedule(payload);
+ } else {
+ await updatePlantCareSchedule(editors.editingScheduleId, payload);
+ }
+ editors.cancelEditingSchedule();
await loadPlantsAndCareTasks();
} catch {
- setError('Could not apply the care schedule.');
+ setError('Could not save the care schedule.');
} finally {
setIsSaving(false);
}
}
- async function removeBulkSchedule() {
- if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) {
- setError('Select an activity and at least one plant.');
+ async function removeSchedule(schedule: PlantCareScheduleRule) {
+ const confirmed = window.confirm(`Delete ${schedule.action} schedule?`);
+ if (!confirmed) {
return;
}
setIsSaving(true);
try {
- await removePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
- editors.setBulkScheduleForm(emptyBulkScheduleForm);
+ await deletePlantCareSchedule(schedule.id);
+ if (editors.editingScheduleId === schedule.id) {
+ editors.cancelEditingSchedule();
+ }
await loadPlantsAndCareTasks();
} catch {
- setError('Could not remove the care schedule.');
+ setError('Could not delete the care schedule.');
} finally {
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 {
assignFlagToSelectedPlant,
applyCatalogImportFile,
@@ -776,18 +794,20 @@ export function useAppActions({
completeBulkTasks,
completeTask,
exportSpreadsheet,
+ dismissCare,
isImportingCatalog,
hasSearchedPlantInfo,
isExporting,
isSearchingPlantInfo,
isSaving,
+ logCare,
importTaxonFromPlantInfo,
plantInfoQuery,
plantInfoResults,
removeAction,
removeActivity,
removeAssignedPlantFlag,
- removeBulkSchedule,
+ removeSchedule,
removeFlagDefinition,
removeLocation,
removePlant,
@@ -805,7 +825,6 @@ export function useAppActions({
savePlantGroup,
saveRecipe,
saveResource,
- saveTaxon,
searchTaxonInfo,
previewCatalogImportFile,
setPlantInfoQuery,
diff --git a/plant-manager-web/src/use-app-editors.ts b/plant-manager-web/src/use-app-editors.ts
index 0d19d26..bf46278 100644
--- a/plant-manager-web/src/use-app-editors.ts
+++ b/plant-manager-web/src/use-app-editors.ts
@@ -4,7 +4,7 @@ import type {
CareActivity,
CareAction,
Plant,
- PlantInfoSearchResult,
+ PlantCareScheduleRule,
PlantFlagDefinition,
PlantGroup,
PlantLocation,
@@ -22,17 +22,15 @@ import {
emptyPlantFlagForm,
emptyRecipeForm,
emptyResourceForm,
- emptyTaxonForm,
toActionForm,
toActivityForm,
+ toBulkScheduleForm,
toFlagDefinitionForm,
toLocationForm,
toPlantForm,
toPlantGroupForm,
toRecipeForm,
toResourceForm,
- toTaxonForm,
- toTaxonFormFromPlantInfo,
type ActionFormState,
type ActivityFormState,
type BulkScheduleFormState,
@@ -43,7 +41,6 @@ import {
type PlantFlagFormState,
type RecipeFormState,
type ResourceFormState,
- type TaxonFormState,
type View,
} from './form-state';
@@ -61,7 +58,6 @@ type AppEditorData = {
export function useAppEditors(data: AppEditorData, setView: (view: View) => void) {
const [editingPlantId, setEditingPlantId] = useState(null);
- const [editingTaxonId, setEditingTaxonId] = useState(null);
const [editingLocationId, setEditingLocationId] = useState(null);
const [editingPlantGroupId, setEditingPlantGroupId] = useState(null);
const [editingActionId, setEditingActionId] = useState(null);
@@ -69,8 +65,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const [editingRecipeId, setEditingRecipeId] = useState(null);
const [editingActivityId, setEditingActivityId] = useState(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState(null);
+ const [editingScheduleId, setEditingScheduleId] = useState(null);
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
- const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
@@ -87,7 +83,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const [selectedActivityId, setSelectedActivityId] = useState(null);
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState(null);
const [form, setForm] = useState(emptyPlantForm);
- const [taxonForm, setTaxonForm] = useState(emptyTaxonForm);
const [locationForm, setLocationForm] = useState(emptyLocationForm);
const [plantGroupForm, setPlantGroupForm] = useState(emptyPlantGroupForm);
const [actionForm, setActionForm] = useState(emptyActionForm);
@@ -100,7 +95,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
const activePlant = data.plants.find((plant) => plant.id === editingPlantId);
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 activeLocation = data.plantLocations.find((location) => location.id === editingLocationId);
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 }));
}
- function updateTaxonForm(field: keyof TaxonFormState, value: string) {
- setTaxonForm((current) => ({ ...current, [field]: value }));
- }
-
function updateLocationForm(field: keyof LocationFormState, value: string) {
setLocationForm((current) => ({ ...current, [field]: value }));
}
@@ -163,6 +153,23 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
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() {
setEditingPlantId(null);
setSelectedPlantId(null);
@@ -195,36 +202,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
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() {
setEditingLocationId(null);
setSelectedLocationId(null);
@@ -406,9 +383,9 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
activePlantGroup,
activeRecipe,
activeResource,
- activeTaxon,
activityForm,
bulkScheduleForm,
+ cancelEditingSchedule,
cancelEditing,
cancelEditingAction,
cancelEditingActivity,
@@ -417,7 +394,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
cancelEditingPlantGroup,
cancelEditingRecipe,
cancelEditingResource,
- cancelEditingTaxon,
editingActionId,
editingActivityId,
editingFlagDefinitionId,
@@ -426,7 +402,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
editingPlantId,
editingRecipeId,
editingResourceId,
- editingTaxonId,
+ editingScheduleId,
flagDefinitionForm,
form,
isActionEditorOpen,
@@ -437,7 +413,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
isPlantGroupEditorOpen,
isRecipeEditorOpen,
isResourceEditorOpen,
- isTaxonEditorOpen,
locationForm,
openPlantDetail,
plantFlagForm,
@@ -464,7 +439,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
setEditingPlantId,
setEditingRecipeId,
setEditingResourceId,
- setEditingTaxonId,
+ setEditingScheduleId,
setPlantFlagForm,
setSelectedActionId,
setSelectedActivityId,
@@ -482,8 +457,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
startAddingPlantGroup,
startAddingRecipe,
startAddingResource,
- startAddingTaxon,
- startAddingTaxonFromPlantInfo,
startEditingAction,
startEditingActivity,
startEditingFlagDefinition,
@@ -492,8 +465,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
startEditingPlantGroup,
startEditingRecipe,
startEditingResource,
- startEditingTaxon,
- taxonForm,
+ startEditingSchedule,
+ startNewSchedule,
updateActionForm,
updateActivityForm,
updateBulkScheduleForm,
@@ -504,6 +477,5 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
updatePlantGroupForm,
updateRecipeForm,
updateResourceForm,
- updateTaxonForm,
};
}
diff --git a/plant-manager-web/src/use-dashboard-data.ts b/plant-manager-web/src/use-dashboard-data.ts
index 7530eb5..4426b72 100644
--- a/plant-manager-web/src/use-dashboard-data.ts
+++ b/plant-manager-web/src/use-dashboard-data.ts
@@ -5,6 +5,7 @@ import {
getCareActions,
getCareTasks,
getPlants,
+ getPlantCareSchedules,
getPlantFlags,
getPlantGroups,
getPlantLocations,
@@ -17,6 +18,7 @@ import type {
CareAction,
CareTask,
Plant,
+ PlantCareScheduleRule,
PlantFlagDefinition,
PlantGroup,
PlantLocation,
@@ -33,6 +35,7 @@ export function useDashboardData() {
const [actionResources, setActionResources] = useState([]);
const [recipes, setRecipes] = useState([]);
const [careActivities, setCareActivities] = useState([]);
+ const [plantCareSchedules, setPlantCareSchedules] = useState([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState([]);
const [careTasks, setCareTasks] = useState([]);
const [isLoading, setIsLoading] = useState(true);
@@ -43,6 +46,7 @@ export function useDashboardData() {
const [
plantsResponse,
tasksResponse,
+ schedulesResponse,
taxaResponse,
locationsResponse,
groupsResponse,
@@ -54,6 +58,7 @@ export function useDashboardData() {
] = await Promise.all([
getPlants(),
getCareTasks(),
+ getPlantCareSchedules(),
getPlantTaxa(),
getPlantLocations(),
getPlantGroups(),
@@ -67,6 +72,7 @@ export function useDashboardData() {
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
+ setPlantCareSchedules(schedulesResponse);
setPlantTaxa(taxaResponse);
setPlantLocations(locationsResponse);
setPlantGroups(groupsResponse);
@@ -108,14 +114,16 @@ export function useDashboardData() {
}
async function loadPlantsAndCareTasks() {
- const [plantsResponse, tasksResponse] = await Promise.all([
+ const [plantsResponse, tasksResponse, schedulesResponse] = await Promise.all([
getPlants(),
getCareTasks(),
+ getPlantCareSchedules(),
]);
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
+ setPlantCareSchedules(schedulesResponse);
}
async function loadTaxaAndPlants() {
@@ -144,6 +152,7 @@ export function useDashboardData() {
const [
plantsResponse,
tasksResponse,
+ schedulesResponse,
actionsResponse,
resourcesResponse,
recipesResponse,
@@ -151,6 +160,7 @@ export function useDashboardData() {
] = await Promise.all([
getPlants(),
getCareTasks(),
+ getPlantCareSchedules(),
getCareActions(),
getActionResources(),
getRecipes(),
@@ -160,6 +170,7 @@ export function useDashboardData() {
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
+ setPlantCareSchedules(schedulesResponse);
setCareActions(actionsResponse);
setActionResources(resourcesResponse);
setRecipes(recipesResponse);
@@ -206,6 +217,7 @@ export function useDashboardData() {
loadRecipesAndResources,
loadTaxaAndPlants,
plantFlagDefinitions,
+ plantCareSchedules,
plantGroups,
plantLocations,
plantTaxa,
diff --git a/plant-manager/Contracts.cs b/plant-manager/Contracts.cs
index 32cc839..39dbe98 100644
--- a/plant-manager/Contracts.cs
+++ b/plant-manager/Contracts.cs
@@ -18,6 +18,7 @@ namespace plant_manager
public record SavePlantCareScheduleRequest(
int CareActivityId,
+ IReadOnlyList? PlantIds,
int? EveryDays,
DateOnly? ScheduledFor,
string? RecurrenceMode,
@@ -41,18 +42,6 @@ namespace plant_manager
DateOnly? EndsOn,
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(
string Source,
string ExternalId,
@@ -110,12 +99,50 @@ namespace plant_manager
string? Unit,
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 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(
int ActionResourceId,
string Name,
decimal? Quantity,
string? Unit,
- string? Notes)
+ string? Notes,
+ CareActivityRecipeDto? ProducedByRecipe)
{
public static CareActivityActionResourceDto FromCareActivityActionResource(
CareActivityActionResource resource) =>
@@ -124,7 +151,10 @@ namespace plant_manager
resource.ActionResource.Name,
resource.Quantity,
resource.Unit,
- resource.Notes);
+ resource.Notes,
+ resource.ActionResource.ProducedByRecipe is null
+ ? null
+ : CareActivityRecipeDto.FromRecipe(resource.ActionResource.ProducedByRecipe));
}
public record CareActivityActionDto(
@@ -186,6 +216,12 @@ namespace plant_manager
string? Notes,
IReadOnlyList? Resources);
+ public record DismissCareTasksRequest(
+ int CareActivityId,
+ IReadOnlyList PlantIds,
+ DateOnly? DismissedOn,
+ string? Notes);
+
public record CatalogImportIssue(
string Sheet,
int Row,
@@ -401,21 +437,23 @@ namespace plant_manager
public static PlantDto FromPlant(Plant plant)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
- var schedules = plant.CareSchedules
- .OrderBy(schedule => schedule.CareActivity.Name)
- .Select(schedule => PlantCareScheduleDto.FromSchedule(
- schedule,
- GetLatestPerformedOn(plant, schedule.CareActivityId),
+ var schedules = plant.CareScheduleAssignments
+ .OrderBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
+ .Select(assignment => PlantCareScheduleDto.FromAssignment(
+ assignment,
+ GetLatestCareEventOn(plant, assignment.PlantCareSchedule.CareActivityId),
today))
.ToList();
var nextCare = schedules
.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(
source,
schedule.LastPerformedOn,
- plant.ActionLogs.Count(log => log.CareActivityId == source.CareActivityId));
+ GetCompletedOccurrences(plant, source.CareActivityId));
})
.Where(date => date is not null)
.OrderBy(date => date)
@@ -449,11 +487,18 @@ namespace plant_manager
schedules);
}
- private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) =>
+ private static DateOnly? GetLatestCareEventOn(Plant plant, int careActivityId) =>
plant.ActionLogs
.Where(log => log.CareActivityId == careActivityId)
.Select(log => (DateOnly?)log.PerformedOn)
+ .Concat(plant.CareDismissals
+ .Where(dismissal => dismissal.CareActivityId == careActivityId)
+ .Select(dismissal => (DateOnly?)dismissal.DismissedOn))
.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(
@@ -475,12 +520,13 @@ namespace plant_manager
string NextCare,
string Status)
{
- public static PlantCareScheduleDto FromSchedule(
- PlantCareSchedule schedule,
+ public static PlantCareScheduleDto FromAssignment(
+ PlantCareScheduleAssignment assignment,
DateOnly? lastPerformedOn,
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(
schedule.Id,
@@ -502,8 +548,54 @@ namespace plant_manager
PlantCareFormatter.GetStatus(nextCare, today));
}
- private static int GetCompletedOccurrences(PlantCareSchedule schedule) =>
- schedule.Plant.ActionLogs.Count(log => log.CareActivityId == schedule.CareActivityId);
+ private static int GetCompletedOccurrences(PlantCareScheduleAssignment assignment) =>
+ 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 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
@@ -532,18 +624,19 @@ namespace plant_manager
string Due,
string Status)
{
- public static CareTaskDto FromSchedule(
- PlantCareSchedule schedule,
+ public static CareTaskDto FromAssignment(
+ PlantCareScheduleAssignment assignment,
DateOnly? lastPerformedOn,
int completedOccurrences,
DateOnly today)
{
+ var schedule = assignment.PlantCareSchedule;
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
return new CareTaskDto(
schedule.Id,
- schedule.PlantId,
- schedule.Plant.Nickname,
+ assignment.PlantId,
+ assignment.Plant.Nickname,
schedule.CareActivityId,
schedule.CareActionId,
schedule.CareActivity.Name,
diff --git a/plant-manager/Data/ApplicationDbContext.cs b/plant-manager/Data/ApplicationDbContext.cs
index e7754d2..13a2ed6 100644
--- a/plant-manager/Data/ApplicationDbContext.cs
+++ b/plant-manager/Data/ApplicationDbContext.cs
@@ -15,7 +15,9 @@ namespace plant_manager.Data
public DbSet CareActivityActionResources { get; set; }
public DbSet ActionLogs { get; set; }
public DbSet ActionLogResources { get; set; }
+ public DbSet CareDismissals { get; set; }
public DbSet PlantCareSchedules { get; set; }
+ public DbSet PlantCareScheduleAssignments { get; set; }
public DbSet PlantFlagDefinitions { get; set; }
public DbSet PlantFlags { get; set; }
public DbSet Recipes { get; set; }
@@ -40,9 +42,9 @@ namespace plant_manager.Data
entity.Property(e => e.Authority).HasMaxLength(120);
entity.Property(e => e.Family).HasMaxLength(120);
entity.Property(e => e.CommonName).HasMaxLength(120);
- entity.Property(e => e.ExternalSource).HasMaxLength(40);
- entity.Property(e => e.ExternalId).HasMaxLength(80);
- entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique(false);
+ entity.Property(e => e.ExternalSource).HasMaxLength(40).IsRequired();
+ entity.Property(e => e.ExternalId).HasMaxLength(80).IsRequired();
+ entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique();
});
modelBuilder.Entity(entity =>
@@ -165,6 +167,23 @@ namespace plant_manager.Data
.OnDelete(DeleteBehavior.Restrict);
});
+ modelBuilder.Entity(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(entity =>
{
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.EndsOn);
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)
.WithMany(e => e.PlantCareSchedules)
.HasForeignKey(e => e.CareActionId)
@@ -195,6 +208,20 @@ namespace plant_manager.Data
.OnDelete(DeleteBehavior.Restrict);
});
+ modelBuilder.Entity(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(entity =>
{
entity.HasKey(e => e.Id);
diff --git a/plant-manager/Data/Migrations/20260612235128_InitialCreate.Designer.cs b/plant-manager/Data/Migrations/20260614222157_InitialCreate.Designer.cs
similarity index 89%
rename from plant-manager/Data/Migrations/20260612235128_InitialCreate.Designer.cs
rename to plant-manager/Data/Migrations/20260614222157_InitialCreate.Designer.cs
index f1b0c3f..c78a5bf 100644
--- a/plant-manager/Data/Migrations/20260612235128_InitialCreate.Designer.cs
+++ b/plant-manager/Data/Migrations/20260614222157_InitialCreate.Designer.cs
@@ -11,7 +11,7 @@ using plant_manager.Data;
namespace plant_manager.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
- [Migration("20260612235128_InitialCreate")]
+ [Migration("20260614222157_InitialCreate")]
partial class InitialCreate
{
///
@@ -201,6 +201,34 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActivityActionResources");
});
+ modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CareActivityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DismissedOn")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("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 =>
{
b.Property("Id")
@@ -256,9 +284,6 @@ namespace plant_manager.Data.Migrations
b.Property("EveryDays")
.HasColumnType("INTEGER");
- b.Property("PlantId")
- .HasColumnType("INTEGER");
-
b.Property("RecurrenceMode")
.IsRequired()
.HasMaxLength(20)
@@ -285,12 +310,23 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActivityId");
- b.HasIndex("PlantId", "CareActionId");
+ b.ToTable("PlantCareSchedules");
+ });
- b.HasIndex("PlantId", "CareActivityId")
+ modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
+ {
+ b.Property("PlantCareScheduleId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlantId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("PlantCareScheduleId", "PlantId");
+
+ b.HasIndex("PlantId", "PlantCareScheduleId")
.IsUnique();
- b.ToTable("PlantCareSchedules");
+ b.ToTable("PlantCareScheduleAssignments");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -428,10 +464,12 @@ namespace plant_manager.Data.Migrations
.HasColumnType("TEXT");
b.Property("ExternalId")
+ .IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property("ExternalSource")
+ .IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
@@ -460,7 +498,8 @@ namespace plant_manager.Data.Migrations
b.HasKey("Id");
- b.HasIndex("ExternalSource", "ExternalId");
+ b.HasIndex("ExternalSource", "ExternalId")
+ .IsUnique();
b.ToTable("PlantTaxa");
});
@@ -616,6 +655,25 @@ namespace plant_manager.Data.Migrations
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 =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -647,17 +705,28 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.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")
- .WithMany("CareSchedules")
+ .WithMany("CareScheduleAssignments")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
- b.Navigation("CareAction");
-
- b.Navigation("CareActivity");
-
b.Navigation("Plant");
+
+ b.Navigation("PlantCareSchedule");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -758,6 +827,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("Actions");
+ b.Navigation("CareDismissals");
+
b.Navigation("PlantCareSchedules");
});
@@ -770,13 +841,20 @@ namespace plant_manager.Data.Migrations
{
b.Navigation("ActionLogs");
- b.Navigation("CareSchedules");
+ b.Navigation("CareDismissals");
+
+ b.Navigation("CareScheduleAssignments");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
});
+ modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
+ {
+ b.Navigation("Assignments");
+ });
+
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
{
b.Navigation("PlantFlags");
diff --git a/plant-manager/Data/Migrations/20260612235128_InitialCreate.cs b/plant-manager/Data/Migrations/20260614222157_InitialCreate.cs
similarity index 89%
rename from plant-manager/Data/Migrations/20260612235128_InitialCreate.cs
rename to plant-manager/Data/Migrations/20260614222157_InitialCreate.cs
index 81b58d5..7b4c1e2 100644
--- a/plant-manager/Data/Migrations/20260612235128_InitialCreate.cs
+++ b/plant-manager/Data/Migrations/20260614222157_InitialCreate.cs
@@ -109,8 +109,8 @@ namespace plant_manager.Data.Migrations
Authority = table.Column(type: "TEXT", maxLength: 120, nullable: true),
Family = table.Column(type: "TEXT", maxLength: 120, nullable: true),
CommonName = table.Column(type: "TEXT", maxLength: 120, nullable: true),
- ExternalSource = table.Column(type: "TEXT", maxLength: 40, nullable: true),
- ExternalId = table.Column(type: "TEXT", maxLength: 80, nullable: true)
+ ExternalSource = table.Column(type: "TEXT", maxLength: 40, nullable: false),
+ ExternalId = table.Column(type: "TEXT", maxLength: 80, nullable: false)
},
constraints: table =>
{
@@ -164,6 +164,41 @@ namespace plant_manager.Data.Migrations
onDelete: ReferentialAction.Cascade);
});
+ migrationBuilder.CreateTable(
+ name: "PlantCareSchedules",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ CareActionId = table.Column(type: "INTEGER", nullable: false),
+ CareActivityId = table.Column(type: "INTEGER", nullable: false),
+ EveryDays = table.Column(type: "INTEGER", nullable: false),
+ ScheduledFor = table.Column(type: "TEXT", nullable: true),
+ RecurrenceMode = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ RepeatEvery = table.Column(type: "INTEGER", nullable: false),
+ RepeatUnit = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ RepeatOnDays = table.Column(type: "TEXT", maxLength: 40, nullable: true),
+ EndsMode = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ EndsOn = table.Column(type: "TEXT", nullable: true),
+ EndsAfterOccurrences = table.Column(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(
name: "Plants",
columns: table => new
@@ -285,41 +320,51 @@ namespace plant_manager.Data.Migrations
});
migrationBuilder.CreateTable(
- name: "PlantCareSchedules",
+ name: "CareDismissals",
columns: table => new
{
Id = table.Column(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column(type: "INTEGER", nullable: false),
- CareActionId = table.Column(type: "INTEGER", nullable: false),
CareActivityId = table.Column(type: "INTEGER", nullable: false),
- EveryDays = table.Column(type: "INTEGER", nullable: false),
- ScheduledFor = table.Column(type: "TEXT", nullable: true),
- RecurrenceMode = table.Column(type: "TEXT", maxLength: 20, nullable: false),
- RepeatEvery = table.Column(type: "INTEGER", nullable: false),
- RepeatUnit = table.Column(type: "TEXT", maxLength: 20, nullable: false),
- RepeatOnDays = table.Column(type: "TEXT", maxLength: 40, nullable: true),
- EndsMode = table.Column(type: "TEXT", maxLength: 20, nullable: false),
- EndsOn = table.Column(type: "TEXT", nullable: true),
- EndsAfterOccurrences = table.Column(type: "INTEGER", nullable: true)
+ DismissedOn = table.Column(type: "TEXT", nullable: false),
+ Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
- table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
+ table.PrimaryKey("PK_CareDismissals", 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",
+ name: "FK_CareDismissals_CareActivities_CareActivityId",
column: x => x.CareActivityId,
principalTable: "CareActivities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
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(type: "INTEGER", nullable: false),
+ PlantId = table.Column(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,
principalTable: "Plants",
principalColumn: "Id",
@@ -459,6 +504,22 @@ namespace plant_manager.Data.Migrations
columns: new[] { "CareActivityId", "SortOrder" },
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(
name: "IX_PlantCareSchedules_CareActionId",
table: "PlantCareSchedules",
@@ -469,17 +530,6 @@ namespace plant_manager.Data.Migrations
table: "PlantCareSchedules",
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(
name: "IX_PlantFlagDefinitions_Name",
table: "PlantFlagDefinitions",
@@ -526,7 +576,8 @@ namespace plant_manager.Data.Migrations
migrationBuilder.CreateIndex(
name: "IX_PlantTaxa_ExternalSource_ExternalId",
table: "PlantTaxa",
- columns: new[] { "ExternalSource", "ExternalId" });
+ columns: new[] { "ExternalSource", "ExternalId" },
+ unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecipeComponents_ActionResourceId",
@@ -562,7 +613,10 @@ namespace plant_manager.Data.Migrations
name: "CareActivityActionResources");
migrationBuilder.DropTable(
- name: "PlantCareSchedules");
+ name: "CareDismissals");
+
+ migrationBuilder.DropTable(
+ name: "PlantCareScheduleAssignments");
migrationBuilder.DropTable(
name: "PlantFlags");
@@ -579,6 +633,9 @@ namespace plant_manager.Data.Migrations
migrationBuilder.DropTable(
name: "CareActivityActions");
+ migrationBuilder.DropTable(
+ name: "PlantCareSchedules");
+
migrationBuilder.DropTable(
name: "PlantFlagDefinitions");
diff --git a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 2cc0853..e0746c7 100644
--- a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -198,6 +198,34 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActivityActionResources");
});
+ modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CareActivityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DismissedOn")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("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 =>
{
b.Property("Id")
@@ -253,9 +281,6 @@ namespace plant_manager.Data.Migrations
b.Property("EveryDays")
.HasColumnType("INTEGER");
- b.Property("PlantId")
- .HasColumnType("INTEGER");
-
b.Property("RecurrenceMode")
.IsRequired()
.HasMaxLength(20)
@@ -282,12 +307,23 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActivityId");
- b.HasIndex("PlantId", "CareActionId");
+ b.ToTable("PlantCareSchedules");
+ });
- b.HasIndex("PlantId", "CareActivityId")
+ modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
+ {
+ b.Property("PlantCareScheduleId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlantId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("PlantCareScheduleId", "PlantId");
+
+ b.HasIndex("PlantId", "PlantCareScheduleId")
.IsUnique();
- b.ToTable("PlantCareSchedules");
+ b.ToTable("PlantCareScheduleAssignments");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -425,10 +461,12 @@ namespace plant_manager.Data.Migrations
.HasColumnType("TEXT");
b.Property("ExternalId")
+ .IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property("ExternalSource")
+ .IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
@@ -457,7 +495,8 @@ namespace plant_manager.Data.Migrations
b.HasKey("Id");
- b.HasIndex("ExternalSource", "ExternalId");
+ b.HasIndex("ExternalSource", "ExternalId")
+ .IsUnique();
b.ToTable("PlantTaxa");
});
@@ -613,6 +652,25 @@ namespace plant_manager.Data.Migrations
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 =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -644,17 +702,28 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.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")
- .WithMany("CareSchedules")
+ .WithMany("CareScheduleAssignments")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
- b.Navigation("CareAction");
-
- b.Navigation("CareActivity");
-
b.Navigation("Plant");
+
+ b.Navigation("PlantCareSchedule");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
@@ -755,6 +824,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("Actions");
+ b.Navigation("CareDismissals");
+
b.Navigation("PlantCareSchedules");
});
@@ -767,13 +838,20 @@ namespace plant_manager.Data.Migrations
{
b.Navigation("ActionLogs");
- b.Navigation("CareSchedules");
+ b.Navigation("CareDismissals");
+
+ b.Navigation("CareScheduleAssignments");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
});
+ modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
+ {
+ b.Navigation("Assignments");
+ });
+
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
{
b.Navigation("PlantFlags");
diff --git a/plant-manager/Data/Models/CareActivity.cs b/plant-manager/Data/Models/CareActivity.cs
index 8b0439c..dcd27a0 100644
--- a/plant-manager/Data/Models/CareActivity.cs
+++ b/plant-manager/Data/Models/CareActivity.cs
@@ -9,6 +9,7 @@ namespace plant_manager.Data.Models
public List Actions { get; set; } = [];
public List ActionLogs { get; set; } = [];
+ public List CareDismissals { get; set; } = [];
public List PlantCareSchedules { get; set; } = [];
}
}
diff --git a/plant-manager/Data/Models/CareDismissal.cs b/plant-manager/Data/Models/CareDismissal.cs
new file mode 100644
index 0000000..1828312
--- /dev/null
+++ b/plant-manager/Data/Models/CareDismissal.cs
@@ -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!;
+ }
+}
diff --git a/plant-manager/Data/Models/Plant.cs b/plant-manager/Data/Models/Plant.cs
index 0bc1ec9..4f3d967 100644
--- a/plant-manager/Data/Models/Plant.cs
+++ b/plant-manager/Data/Models/Plant.cs
@@ -11,7 +11,8 @@ namespace plant_manager.Data.Models
public PlantTaxon? Taxon { get; set; }
public PlantLocation? Location { get; set; }
public List ActionLogs { get; set; } = [];
- public List CareSchedules { get; set; } = [];
+ public List CareDismissals { get; set; } = [];
+ public List CareScheduleAssignments { get; set; } = [];
public List Flags { get; set; } = [];
public List GroupMemberships { get; set; } = [];
}
diff --git a/plant-manager/Data/Models/PlantCareSchedule.cs b/plant-manager/Data/Models/PlantCareSchedule.cs
index 5082d8c..beb2de6 100644
--- a/plant-manager/Data/Models/PlantCareSchedule.cs
+++ b/plant-manager/Data/Models/PlantCareSchedule.cs
@@ -3,7 +3,6 @@ namespace plant_manager.Data.Models
public class PlantCareSchedule
{
public int Id { get; set; }
- public int PlantId { get; set; }
public int CareActionId { get; set; }
public int CareActivityId { get; set; }
public int EveryDays { get; set; } = 7;
@@ -16,8 +15,8 @@ namespace plant_manager.Data.Models
public DateOnly? EndsOn { get; set; }
public int? EndsAfterOccurrences { get; set; }
- public Plant Plant { get; set; } = null!;
public CareAction CareAction { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!;
+ public List Assignments { get; set; } = [];
}
}
diff --git a/plant-manager/Data/Models/PlantCareScheduleAssignment.cs b/plant-manager/Data/Models/PlantCareScheduleAssignment.cs
new file mode 100644
index 0000000..326aae4
--- /dev/null
+++ b/plant-manager/Data/Models/PlantCareScheduleAssignment.cs
@@ -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!;
+ }
+}
diff --git a/plant-manager/Data/Models/PlantTaxon.cs b/plant-manager/Data/Models/PlantTaxon.cs
index b09fedd..c9a44bf 100644
--- a/plant-manager/Data/Models/PlantTaxon.cs
+++ b/plant-manager/Data/Models/PlantTaxon.cs
@@ -13,7 +13,7 @@ namespace plant_manager.Data.Models
public string? Authority { get; set; }
public string? Family { get; set; }
public string? CommonName { get; set; }
- public string? ExternalSource { get; set; }
- public string? ExternalId { get; set; }
+ public string ExternalSource { get; set; } = string.Empty;
+ public string ExternalId { get; set; } = string.Empty;
}
}
diff --git a/plant-manager/Data/SeedData.cs b/plant-manager/Data/SeedData.cs
new file mode 100644
index 0000000..b3e9093
--- /dev/null
+++ b/plant-manager/Data/SeedData.cs
@@ -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();
+ }
+ }
+}
diff --git a/plant-manager/Endpoints/ActionLogEndpoints.cs b/plant-manager/Endpoints/ActionLogEndpoints.cs
index 7588705..9b25950 100644
--- a/plant-manager/Endpoints/ActionLogEndpoints.cs
+++ b/plant-manager/Endpoints/ActionLogEndpoints.cs
@@ -76,6 +76,80 @@ namespace plant_manager.Endpoints
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) =>
{
var log = await db.ActionLogs
diff --git a/plant-manager/Endpoints/CareActivityEndpoints.cs b/plant-manager/Endpoints/CareActivityEndpoints.cs
index 142cd2b..a021446 100644
--- a/plant-manager/Endpoints/CareActivityEndpoints.cs
+++ b/plant-manager/Endpoints/CareActivityEndpoints.cs
@@ -16,6 +16,9 @@ namespace plant_manager.Endpoints
.Include(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
+ .ThenInclude(resource => resource.ProducedByRecipe)
+ .ThenInclude(recipe => recipe!.Components)
+ .ThenInclude(component => component.ActionResource)
.OrderBy(activity => activity.Name)
.Select(activity => CareActivityDto.FromCareActivity(activity))
.ToListAsync();
@@ -59,6 +62,9 @@ namespace plant_manager.Endpoints
.Include(item => item.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
+ .ThenInclude(resource => resource.ProducedByRecipe)
+ .ThenInclude(recipe => recipe!.Components)
+ .ThenInclude(component => component.ActionResource)
.FirstOrDefaultAsync(item => item.Id == id);
if (activity is null)
{
diff --git a/plant-manager/Endpoints/CareTaskEndpoints.cs b/plant-manager/Endpoints/CareTaskEndpoints.cs
index 1539294..3586cb0 100644
--- a/plant-manager/Endpoints/CareTaskEndpoints.cs
+++ b/plant-manager/Endpoints/CareTaskEndpoints.cs
@@ -11,41 +11,32 @@ namespace plant_manager.Endpoints
async Task GetUpcomingCareTasks(ApplicationDbContext db)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
- var schedules = await db.PlantCareSchedules
- .Include(schedule => schedule.Plant)
- .Include(schedule => schedule.CareAction)
- .Include(schedule => schedule.CareActivity)
+ var assignments = await db.PlantCareScheduleAssignments
+ .Include(assignment => assignment.Plant)
+ .ThenInclude(plant => plant.CareDismissals)
+ .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(action => action.CareAction)
- .Include(schedule => schedule.CareActivity)
+ .Include(assignment => assignment.PlantCareSchedule)
+ .ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
- .OrderBy(schedule => schedule.Plant.Nickname)
- .ThenBy(schedule => schedule.CareActivity.Name)
+ .OrderBy(assignment => assignment.Plant.Nickname)
+ .ThenBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
.ToListAsync();
- var latestLogs = await db.ActionLogs
- .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 (latestLogLookup, completedLookup) = await GetCareProgress(db);
- var tasks = schedules
- .Select(schedule => CareTaskDto.FromSchedule(
- schedule,
- latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
- completedLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
+ var tasks = assignments
+ .Select(assignment => CareTaskDto.FromAssignment(
+ assignment,
+ latestLogLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
+ completedLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
today))
.Where(task => task.Status is "due" or "soon")
.ToList();
@@ -88,14 +79,15 @@ namespace plant_manager.Endpoints
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
- var schedules = await db.PlantCareSchedules
- .Include(schedule => schedule.Plant)
- .Where(schedule =>
- schedule.CareActivityId == activity.Id
- && plantIds.Contains(schedule.PlantId))
+ 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 = schedules
- .Select(schedule => schedule.PlantId)
+ var schedulePlantIds = assignments
+ .Select(assignment => assignment.PlantId)
.ToHashSet();
if (schedulePlantIds.Count != plantIds.Count)
{
@@ -118,15 +110,33 @@ namespace plant_manager.Endpoints
var completedLookup = latestLogs.ToDictionary(
log => log.PlantId,
log => log.CompletedOccurrences);
- var duePlantIds = schedules
- .Where(schedule =>
+ var dismissals = await db.CareDismissals
+ .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.GetNextCareDate(
- schedule,
- latestLogLookup.GetValueOrDefault(schedule.PlantId),
- completedLookup.GetValueOrDefault(schedule.PlantId)),
+ assignment.PlantCareSchedule,
+ latestLogLookup.GetValueOrDefault(assignment.PlantId),
+ completedLookup.GetValueOrDefault(assignment.PlantId)),
today) == "due")
- .Select(schedule => schedule.PlantId)
+ .Select(assignment => assignment.PlantId)
.ToHashSet();
if (duePlantIds.Count != plantIds.Count)
@@ -166,11 +176,11 @@ namespace plant_manager.Endpoints
var performedOn = request.PerformedOn ?? today;
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
- var logs = schedules
- .OrderBy(schedule => schedule.Plant.Nickname)
- .Select(schedule => new ActionLog
+ var logs = assignments
+ .OrderBy(assignment => assignment.Plant.Nickname)
+ .Select(assignment => new ActionLog
{
- PlantId = schedule.PlantId,
+ PlantId = assignment.PlantId,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
ActionNameSnapshot = activity.Name,
@@ -192,8 +202,139 @@ namespace plant_manager.Endpoints
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? 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 GetConfiguredResources(CareActivity activity) =>
activity.Actions
.SelectMany(action => action.Resources)
diff --git a/plant-manager/Endpoints/ExportEndpoints.cs b/plant-manager/Endpoints/ExportEndpoints.cs
index 8f14b50..1ec18ca 100644
--- a/plant-manager/Endpoints/ExportEndpoints.cs
+++ b/plant-manager/Endpoints/ExportEndpoints.cs
@@ -43,7 +43,8 @@ namespace plant_manager.Endpoints
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.Include(plant => plant.ActionLogs)
.AsSplitQuery()
@@ -65,7 +66,7 @@ namespace plant_manager.Endpoints
.Where(flag => flag.ResolvedOn == null)
.OrderBy(flag => flag.Definition.Name)
.Select(flag => flag.Definition.Name)),
- plant.CareSchedules.Count,
+ plant.CareScheduleAssignments.Count,
plant.ActionLogs.Count
})
.ToList();
@@ -87,18 +88,23 @@ namespace plant_manager.Endpoints
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
{
var schedules = await db.PlantCareSchedules
- .Include(schedule => schedule.Plant)
.Include(schedule => schedule.CareActivity)
.Include(schedule => schedule.CareAction)
- .OrderBy(schedule => schedule.Plant.Nickname)
- .ThenBy(schedule => schedule.CareActivity.Name)
+ .Include(schedule => schedule.Assignments)
+ .ThenInclude(assignment => assignment.Plant)
+ .OrderBy(schedule => schedule.CareActivity.Name)
+ .ThenBy(schedule => schedule.Id)
.ToListAsync();
var rows = schedules
.Select(schedule => new object?[]
{
schedule.Id,
- schedule.PlantId,
- schedule.Plant.Nickname,
+ string.Join(", ", schedule.Assignments
+ .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.CareAction.Name,
schedule.EveryDays,
@@ -115,8 +121,8 @@ namespace plant_manager.Endpoints
AddSheet(workbook, "Care Schedules", [
"ID",
- "Plant ID",
- "Plant",
+ "Plant IDs",
+ "Plants",
"Activity",
"Primary Action",
"Every Days",
diff --git a/plant-manager/Endpoints/ImportEndpoints.cs b/plant-manager/Endpoints/ImportEndpoints.cs
index fd0523d..14fb575 100644
--- a/plant-manager/Endpoints/ImportEndpoints.cs
+++ b/plant-manager/Endpoints/ImportEndpoints.cs
@@ -69,7 +69,7 @@ namespace plant_manager.Endpoints
{
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
}
- await ImportTaxa(workbook, db, context, apply);
+ ImportTaxa(workbook, context);
if (context.Sheets.Count == 0)
{
@@ -343,87 +343,27 @@ namespace plant_manager.Endpoints
AddSummary(context, summary);
}
- private static async Task ImportTaxa(
+ private static void ImportTaxa(
XLWorkbook workbook,
- ApplicationDbContext db,
- ImportContext context,
- bool apply)
+ ImportContext context)
{
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
{
return;
}
- var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]);
- var existing = await db.PlantTaxa.ToListAsync();
- var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
- var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: []);
var summary = new MutableSummary(TaxaSheet);
foreach (var row in rows)
{
- var name = Required(row, "Name", context);
- var genus = Required(row, "Genus", context);
- var species = Required(row, "Species", context);
- if (name is null || genus is null || species is null)
- {
- summary.Skips++;
- continue;
- }
-
- 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
- });
- }
- }
+ context.Issues.Add(new CatalogImportIssue(
+ TaxaSheet,
+ row.Row.RowNumber(),
+ "Taxa",
+ "Taxa must be imported from GBIF search results, not spreadsheet rows.",
+ "error"));
+ summary.Skips++;
}
AddSummary(context, summary);
diff --git a/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs b/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs
index 976dd96..729f8f5 100644
--- a/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs
+++ b/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs
@@ -8,107 +8,170 @@ namespace plant_manager.Endpoints
{
public static void MapPlantCareScheduleEndpoints(this WebApplication app)
{
- app.MapPost("/api/plant-care-schedules/bulk", async (
- BulkSavePlantCareScheduleRequest request,
- ApplicationDbContext db) =>
+ app.MapGet("/api/plant-care-schedules", async (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
- .Where(schedule =>
- schedule.CareActivityId == request.CareActivityId
- && plantIds.Contains(schedule.PlantId))
+ .Include(schedule => schedule.CareActivity)
+ .Include(schedule => schedule.CareAction)
+ .Include(schedule => schedule.Assignments)
+ .ThenInclude(assignment => assignment.Plant)
+ .OrderBy(schedule => schedule.CareActivity.Name)
+ .ThenBy(schedule => schedule.Id)
+ .AsSplitQuery()
.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();
- 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 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 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(
@@ -139,7 +202,7 @@ namespace plant_manager.Endpoints
};
var normalizedEndsMode = mode == "none"
? "after"
- : NormalizeOption(endsMode, new HashSet { "on", "after" }, "after");
+ : NormalizeOption(endsMode, new HashSet { "never", "on", "after" }, "never");
var normalizedEveryDays = mode switch
{
"none" => Math.Clamp(everyDays ?? 7, 1, 365),
@@ -199,5 +262,23 @@ namespace plant_manager.Endpoints
string EndsMode,
DateOnly? EndsOn,
int? EndsAfterOccurrences);
+
+ private sealed record ValidatedScheduleRequest(
+ IReadOnlyList 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 plantIds,
+ CareActivity activity,
+ CareAction primaryAction,
+ ScheduleRecurrence recurrence) =>
+ new(plantIds, activity, primaryAction, recurrence, null);
+ }
}
}
diff --git a/plant-manager/Endpoints/PlantEndpoints.cs b/plant-manager/Endpoints/PlantEndpoints.cs
index 031c458..501c6fe 100644
--- a/plant-manager/Endpoints/PlantEndpoints.cs
+++ b/plant-manager/Endpoints/PlantEndpoints.cs
@@ -13,20 +13,34 @@ namespace plant_manager.Endpoints
var plants = await db.Plants
.Include(plant => plant.Taxon)
.Include(plant => plant.Location)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.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)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
+ .Include(plant => plant.CareDismissals)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
@@ -42,20 +56,34 @@ namespace plant_manager.Endpoints
var plant = await db.Plants
.Include(item => item.Taxon)
.Include(item => item.Location)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
- .Include(plant => plant.CareSchedules)
+ .Include(plant => plant.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.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)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
+ .Include(plant => plant.CareDismissals)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
@@ -79,6 +107,10 @@ namespace plant_manager.Endpoints
{
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);
if (request.LocationId is not null && location is null)
@@ -122,20 +154,34 @@ namespace plant_manager.Endpoints
var plant = await db.Plants
.Include(item => item.Taxon)
.Include(item => item.Location)
- .Include(item => item.CareSchedules)
+ .Include(item => item.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction)
- .Include(item => item.CareSchedules)
+ .Include(item => item.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
- .Include(item => item.CareSchedules)
+ .Include(item => item.CareScheduleAssignments)
+ .ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.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)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
+ .Include(item => item.CareDismissals)
.Include(item => item.Flags)
.ThenInclude(flag => flag.Definition)
.Include(item => item.GroupMemberships)
@@ -152,6 +198,10 @@ namespace plant_manager.Endpoints
{
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);
if (request.LocationId is not null && location is null)
@@ -212,6 +262,7 @@ namespace plant_manager.Endpoints
[
new SavePlantCareScheduleRequest(
waterActivity.Id,
+ null,
7,
null,
"weekly",
@@ -253,28 +304,40 @@ namespace plant_manager.Endpoints
}
var requestedActivityIds = activityIds.ToHashSet();
- var schedulesToRemove = plant.CareSchedules
- .Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId))
+ var assignmentsToRemove = plant.CareScheduleAssignments
+ .Where(assignment => !requestedActivityIds.Contains(assignment.PlantCareSchedule.CareActivityId))
.ToList();
- db.PlantCareSchedules.RemoveRange(schedulesToRemove);
+ db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
foreach (var requestedSchedule in normalizedSchedules)
{
var activity = activitiesById[requestedSchedule.CareActivityId];
var primaryAction = activity.PrimaryAction()!;
- var schedule = plant.CareSchedules
- .FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId);
- if (schedule is null)
+ var assignment = plant.CareScheduleAssignments
+ .FirstOrDefault(item => item.PlantCareSchedule.CareActivityId == requestedSchedule.CareActivityId);
+ var schedule = assignment?.PlantCareSchedule;
+ if (assignment is null)
{
schedule = new PlantCareSchedule
{
- PlantId = plant.Id,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
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;
@@ -295,5 +358,9 @@ namespace plant_manager.Endpoints
return null;
}
+
+ private static bool IsGbifTaxon(PlantTaxon taxon) =>
+ string.Equals(taxon.ExternalSource, "gbif", StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(taxon.ExternalId);
}
}
diff --git a/plant-manager/Endpoints/PlantTaxonEndpoints.cs b/plant-manager/Endpoints/PlantTaxonEndpoints.cs
index f1551a2..2cf76b8 100644
--- a/plant-manager/Endpoints/PlantTaxonEndpoints.cs
+++ b/plant-manager/Endpoints/PlantTaxonEndpoints.cs
@@ -18,64 +18,6 @@ namespace plant_manager.Endpoints
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) =>
{
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) =>
scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
diff --git a/plant-manager/Program.cs b/plant-manager/Program.cs
index c42a679..0dabcc4 100644
--- a/plant-manager/Program.cs
+++ b/plant-manager/Program.cs
@@ -42,6 +42,7 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
db.Database.Migrate();
+ await db.SeedDevelopmentDataAsync();
var plantInfoDb = scope.ServiceProvider.GetRequiredService();
await plantInfoDb.EnsureSearchSchemaAsync();