From 94d91b08e6c777db6e1777034651233943242298 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Mon, 18 May 2026 15:49:06 +0000 Subject: [PATCH] add plant flags, schedules, action logs --- .devcontainer/devcontainer.json | 3 +- README.md | 19 +- docs/schema-table.md | 18 +- dotnet-tools.json | 13 + plant-manager-web/src/App.tsx | 698 ++++++++--- plant-manager-web/src/api.ts | 65 +- .../src/components/ActionsView.tsx | 9 +- .../src/components/FlagsView.tsx | 154 +++ plant-manager-web/src/components/HomeView.tsx | 20 +- .../src/components/PlantCard.tsx | 52 +- .../src/components/PlantsView.tsx | 617 ++++++++-- .../src/components/ResourcesView.tsx | 9 +- plant-manager-web/src/components/TaxaView.tsx | 9 +- plant-manager-web/src/domain.ts | 84 +- plant-manager-web/src/form-state.ts | 80 +- plant-manager-web/src/styles.css | 1052 +++++++++++------ plant-manager/Contracts.cs | 211 +++- plant-manager/Data/ApplicationDbContext.cs | 73 +- plant-manager/Data/DatabaseSeeder.cs | 178 ++- .../20260518153034_InitialCreate.Designer.cs | 415 +++++++ .../20260518153034_InitialCreate.cs | 304 +++++ .../ApplicationDbContextModelSnapshot.cs | 412 +++++++ plant-manager/Data/Models/ActionLog.cs | 5 +- .../Data/Models/ActionLogResource.cs | 13 + plant-manager/Data/Models/ActionResource.cs | 2 + plant-manager/Data/Models/CareAction.cs | 3 + plant-manager/Data/Models/Plant.cs | 4 +- .../Data/Models/PlantCareSchedule.cs | 14 + plant-manager/Data/Models/PlantFlag.cs | 16 + .../Data/Models/PlantFlagDefinition.cs | 13 + plant-manager/Endpoints/ActionLogEndpoints.cs | 151 ++- .../Endpoints/ActionResourceEndpoints.cs | 8 + .../Endpoints/CareActionEndpoints.cs | 2 +- plant-manager/Endpoints/CareTaskEndpoints.cs | 35 +- plant-manager/Endpoints/PlantEndpoints.cs | 105 +- plant-manager/Endpoints/PlantFlagEndpoints.cs | 236 ++++ plant-manager/Endpoints/RootEndpoints.cs | 2 +- plant-manager/Program.cs | 3 +- plant-manager/plant-manager.csproj | 4 + 39 files changed, 4311 insertions(+), 800 deletions(-) create mode 100644 dotnet-tools.json create mode 100644 plant-manager-web/src/components/FlagsView.tsx create mode 100644 plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs create mode 100644 plant-manager/Data/Migrations/20260518153034_InitialCreate.cs create mode 100644 plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 plant-manager/Data/Models/ActionLogResource.cs create mode 100644 plant-manager/Data/Models/PlantCareSchedule.cs create mode 100644 plant-manager/Data/Models/PlantFlag.cs create mode 100644 plant-manager/Data/Models/PlantFlagDefinition.cs create mode 100644 plant-manager/Endpoints/PlantFlagEndpoints.cs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 804454d..b7e7164 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -27,7 +27,7 @@ "onAutoForward": "silent" } }, - "postCreateCommand": "dotnet restore plant-manager.sln && cd plant-manager-web && npm install", + "postCreateCommand": "dotnet restore plant-manager.sln && dotnet tool restore && cd plant-manager-web && npm install", "customizations": { "vscode": { "settings": { @@ -36,6 +36,7 @@ "files.eol": "\n" }, "extensions": [ + "openai.chatgpt", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-dotnettools.csdevkit", diff --git a/README.md b/README.md index 348c0ae..bd8b117 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The container runs dependency setup automatically: ```bash dotnet restore plant-manager.sln +dotnet tool restore cd plant-manager-web && npm install ``` @@ -81,12 +82,14 @@ SQLite is configured in `plant-manager/appsettings.json`: "DefaultConnection": "Data Source=App_Data/plant-man.db" ``` -On startup, the API creates the database with `EnsureCreated()` and seeds a few starter plants. This keeps the project easy to test while the model is still young. +On startup, the API applies EF Core migrations with `Migrate()` and seeds a few starter plants. This keeps the schema versioned while the project is still easy to test. The default starter taxons are tracked in [docs/starter-taxa.md](docs/starter-taxa.md). Local SQLite files are ignored by Git. +If you have a local database created before migrations were added, delete `plant-manager/App_Data/plant-man.db` and restart the API to recreate it from migrations. + ## Project Structure ```text @@ -119,14 +122,24 @@ plant-manager-web/ React + Vite frontend - `POST /api/action-resources` - `PUT /api/action-resources/{id}` - `DELETE /api/action-resources/{id}` -- `GET /api/care-tasks/today` +- `GET /api/care-tasks/upcoming` +- `GET /api/action-logs` - `POST /api/action-logs` +- `PUT /api/action-logs/{id}` +- `DELETE /api/action-logs/{id}` Example action log request: ```json { "plantId": 1, - "action": "Water" + "careActionId": 1, + "resources": [ + { + "actionResourceId": 1, + "quantity": 250, + "unit": "ml" + } + ] } ``` diff --git a/docs/schema-table.md b/docs/schema-table.md index d33c2da..e306d6f 100644 --- a/docs/schema-table.md +++ b/docs/schema-table.md @@ -26,8 +26,18 @@ A plant owned or tracked by the user. | `taxon_id` | `int` | Yes | Foreign key to `PlantTaxon` | | `nickname` | `string` | Yes | User-facing plant name | | `location` | `string` | Yes | Where the plant lives | -| `last_watered_on` | `date` | No | Last watering date | -| `water_every_days` | `int` | Yes | Simple watering interval | + +## `PlantCareSchedule` + +A per-plant recurring schedule for one care action. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `plant_id` | `int` | Yes | Foreign key to `Plant` | +| `care_action_id` | `int` | Yes | Foreign key to `CareAction` | +| `every_days` | `int` | Yes | Recurrence interval | +| `is_enabled` | `bool` | Yes | Whether this schedule contributes to care tasks | ## `ActionLog` @@ -37,7 +47,8 @@ A record of care performed for a plant. | - | - | - | - | | `id` | `int` | Yes | Primary key | | `plant_id` | `int` | Yes | Foreign key to `Plant` | -| `action` | `string` | Yes | Care action, such as `Water` | +| `care_action_id` | `int` | Yes | Foreign key to `CareAction` | +| `action_name_snapshot` | `string` | Yes | Historical display name, such as `Water`, preserved if the action is renamed | | `notes` | `string` | No | Optional observation | | `performed_on` | `date` | Yes | Date care was completed | @@ -82,5 +93,4 @@ These concepts are still promising, but they are intentionally out of the first - user accounts - groups or rooms - grow media and blends -- recurring schedules beyond a simple watering interval - richer taxon profiles diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..ce0aab7 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.7", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/plant-manager-web/src/App.tsx b/plant-manager-web/src/App.tsx index 97ca900..68c1cf5 100644 --- a/plant-manager-web/src/App.tsx +++ b/plant-manager-web/src/App.tsx @@ -1,41 +1,53 @@ -import { BookOpen, Home, ListChecks, Package, Search, Sprout } from 'lucide-react'; +import { Search } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { createActionResource, createCareAction, createPlant, + assignPlantFlag, + createPlantFlag, createPlantTaxon, deleteActionResource, deleteCareAction, deletePlant, + deletePlantFlag, deletePlantTaxon, - getActionLogs, getActionResources, getCareActions, getCareTasks, getPlants, + getPlantFlags, getPlantTaxa, logCare, + removePlantFlagAssignment, + resolvePlantFlag, + updateActionLog, updateActionResource, updateCareAction, updatePlant, + updatePlantFlag, updatePlantTaxon, } from './api'; import { ActionsView } from './components/ActionsView'; +import { FlagsView } from './components/FlagsView'; import { HomeView } from './components/HomeView'; import { PlantsView } from './components/PlantsView'; import { ResourcesView } from './components/ResourcesView'; import { TaxaView } from './components/TaxaView'; -import type { ActionLog, ActionResource, CareAction, CareTask, Plant, PlantTaxon } from './domain'; +import type { ActionResource, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantTaxon } from './domain'; import { emptyActionForm, emptyCareLogForm, + emptyFlagDefinitionForm, emptyPlantForm, + emptyPlantFlagForm, emptyResourceForm, emptyTaxonForm, - formatTaxon, toActionForm, toActionPayload, + toFlagDefinitionForm, + toFlagDefinitionPayload, + toPlantFlagPayload, toPlantForm, toPlantPayload, toResourceForm, @@ -43,7 +55,11 @@ import { toTaxonForm, toTaxonPayload, type ActionFormState, + type CareLogResourceFormState, type CareLogFormState, + type FlagDefinitionFormState, + type PlantCareScheduleFormState, + type PlantFlagFormState, type PlantFormState, type ResourceFormState, type TaxonFormState, @@ -56,7 +72,7 @@ export function App() { const [plantTaxa, setPlantTaxa] = useState([]); const [careActions, setCareActions] = useState([]); const [actionResources, setActionResources] = useState([]); - const [actionLogs, setActionLogs] = useState([]); + const [plantFlagDefinitions, setPlantFlagDefinitions] = useState([]); const [careTasks, setCareTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -64,12 +80,23 @@ export function App() { const [editingTaxonId, setEditingTaxonId] = useState(null); const [editingActionId, setEditingActionId] = useState(null); const [editingResourceId, setEditingResourceId] = useState(null); + const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState(null); + const [editingActionLogId, setEditingActionLogId] = useState(null); + const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); + const [isCareLogEditorOpen, setIsCareLogEditorOpen] = useState(false); + const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false); + const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); + const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false); + const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false); + const [selectedPlantId, setSelectedPlantId] = useState(null); const [form, setForm] = useState(emptyPlantForm); const [taxonForm, setTaxonForm] = useState(emptyTaxonForm); const [actionForm, setActionForm] = useState(emptyActionForm); const [resourceForm, setResourceForm] = useState(emptyResourceForm); + const [flagDefinitionForm, setFlagDefinitionForm] = useState(emptyFlagDefinitionForm); + const [plantFlagForm, setPlantFlagForm] = useState(emptyPlantFlagForm); const [careLogForm, setCareLogForm] = useState(emptyCareLogForm); - const [plantTaxonSearch, setPlantTaxonSearch] = useState(''); + const [plantSearch, setPlantSearch] = useState(''); const [quickTaxonForm, setQuickTaxonForm] = useState(emptyTaxonForm); const [isCreatingPlantTaxon, setIsCreatingPlantTaxon] = useState(false); const [error, setError] = useState(null); @@ -82,14 +109,14 @@ export function App() { taxaResponse, actionsResponse, resourcesResponse, - logsResponse, + flagsResponse, ] = await Promise.all([ getPlants(), getCareTasks(), getPlantTaxa(), getCareActions(), getActionResources(), - getActionLogs(), + getPlantFlags(), ]); setError(null); @@ -98,7 +125,7 @@ export function App() { setPlantTaxa(taxaResponse); setCareActions(actionsResponse); setActionResources(resourcesResponse); - setActionLogs(logsResponse); + setPlantFlagDefinitions(flagsResponse); } catch { setError('Could not reach the Plant-Man API. Start the backend and refresh.'); } finally { @@ -107,20 +134,40 @@ export function App() { } useEffect(() => { - void loadDashboard(); + queueMicrotask(() => { + void loadDashboard(); + }); }, []); const dueCount = useMemo( () => careTasks.filter((task) => task.status === 'due').length, [careTasks], ); + const filteredPlants = useMemo( + () => filterPlants(plants, plantSearch), + [plants, plantSearch], + ); + const filteredPlantIds = useMemo( + () => new Set(filteredPlants.map((plant) => plant.id)), + [filteredPlants], + ); + const filteredCareTasks = useMemo( + () => filterCareTasks(careTasks, filteredPlantIds, plantSearch), + [careTasks, filteredPlantIds, plantSearch], + ); + const visibleDueCount = useMemo( + () => filteredCareTasks.filter((task) => task.status === 'due').length, + [filteredCareTasks], + ); const activePlant = plants.find((plant) => plant.id === editingPlantId); + const selectedPlant = plants.find((plant) => plant.id === selectedPlantId); const activeTaxon = plantTaxa.find((taxon) => taxon.id === editingTaxonId); const activeAction = careActions.find((action) => action.id === editingActionId); const activeResource = actionResources.find((resource) => resource.id === editingResourceId); + const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId); - function updateForm(field: keyof PlantFormState, value: string) { + function updateForm(field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) { setForm((current) => ({ ...current, [field]: value })); } @@ -136,7 +183,18 @@ export function App() { setResourceForm((current) => ({ ...current, [field]: value })); } - function updateCareLogForm(field: keyof CareLogFormState, value: string) { + function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string | boolean) { + setFlagDefinitionForm((current) => ({ ...current, [field]: value })); + } + + function updatePlantFlagForm(field: keyof PlantFlagFormState, value: string) { + setPlantFlagForm((current) => ({ ...current, [field]: value })); + } + + function updateCareLogForm( + field: keyof CareLogFormState, + value: string | CareLogResourceFormState[], + ) { setCareLogForm((current) => ({ ...current, [field]: value })); } @@ -146,26 +204,40 @@ export function App() { function startAddingPlant() { setEditingPlantId(null); - setForm(emptyPlantForm); - setPlantTaxonSearch(''); + setForm({ + ...emptyPlantForm, + careSchedules: getDefaultPlantCareSchedules(careActions), + }); setQuickTaxonForm(emptyTaxonForm); + setIsPlantEditorOpen(true); + setIsCareLogEditorOpen(false); setView('plants'); } + function openPlantDetail(plant: Plant) { + setSelectedPlantId(plant.id); + setView('plants'); + } + + function closePlantDetail() { + setSelectedPlantId(null); + } + function startEditingPlant(plant: Plant) { - const taxon = plantTaxa.find((item) => item.id === plant.taxonId); + setSelectedPlantId(plant.id); setEditingPlantId(plant.id); setForm(toPlantForm(plant)); - setPlantTaxonSearch(taxon ? formatTaxon(taxon) : plant.taxon); setQuickTaxonForm(emptyTaxonForm); + setIsPlantEditorOpen(true); + setIsCareLogEditorOpen(false); setView('plants'); } function cancelEditing() { setEditingPlantId(null); setForm(emptyPlantForm); - setPlantTaxonSearch(''); setQuickTaxonForm(emptyTaxonForm); + setIsPlantEditorOpen(false); } function selectPlantTaxon(taxon: PlantTaxon) { @@ -174,70 +246,110 @@ export function App() { nickname: current.nickname.trim() ? current.nickname : taxon.name, taxonId: String(taxon.id), })); - setPlantTaxonSearch(formatTaxon(taxon)); } function startAddingTaxon() { setEditingTaxonId(null); setTaxonForm(emptyTaxonForm); + setIsTaxonEditorOpen(true); setView('taxa'); } function startEditingTaxon(taxon: PlantTaxon) { setEditingTaxonId(taxon.id); setTaxonForm(toTaxonForm(taxon)); + setIsTaxonEditorOpen(true); setView('taxa'); } function cancelEditingTaxon() { setEditingTaxonId(null); setTaxonForm(emptyTaxonForm); + setIsTaxonEditorOpen(false); } function startAddingAction() { setEditingActionId(null); setActionForm(emptyActionForm); + setIsActionEditorOpen(true); setView('actions'); } function startEditingAction(action: CareAction) { setEditingActionId(action.id); setActionForm(toActionForm(action)); + setIsActionEditorOpen(true); setView('actions'); } function cancelEditingAction() { setEditingActionId(null); setActionForm(emptyActionForm); + setIsActionEditorOpen(false); } function startAddingResource() { setEditingResourceId(null); setResourceForm(emptyResourceForm); + setIsResourceEditorOpen(true); setView('resources'); } function startEditingResource(resource: ActionResource) { setEditingResourceId(resource.id); setResourceForm(toResourceForm(resource)); + setIsResourceEditorOpen(true); setView('resources'); } function cancelEditingResource() { setEditingResourceId(null); setResourceForm(emptyResourceForm); + setIsResourceEditorOpen(false); + } + + function startAddingFlagDefinition() { + setEditingFlagDefinitionId(null); + setFlagDefinitionForm(emptyFlagDefinitionForm); + setIsFlagDefinitionEditorOpen(true); + setView('flags'); + } + + function startEditingFlagDefinition(flag: PlantFlagDefinition) { + setEditingFlagDefinitionId(flag.id); + setFlagDefinitionForm(toFlagDefinitionForm(flag)); + setIsFlagDefinitionEditorOpen(true); + setView('flags'); + } + + function cancelEditingFlagDefinition() { + setEditingFlagDefinitionId(null); + setFlagDefinitionForm(emptyFlagDefinitionForm); + setIsFlagDefinitionEditorOpen(false); } function startLoggingCare(plant?: Plant) { const enabledAction = careActions.find((action) => action.isEnabled); + if (plant) { + setSelectedPlantId(plant.id); + } + setEditingActionLogId(null); setCareLogForm((current) => ({ ...current, plantId: plant ? String(plant.id) : current.plantId, - action: current.action || enabledAction?.name || 'Water', + careActionId: current.careActionId || (enabledAction ? String(enabledAction.id) : ''), })); + setIsCareLogEditorOpen(true); + setIsPlantEditorOpen(false); setView('plants'); } + function cancelCareLog() { + setEditingActionLogId(null); + setCareLogForm(emptyCareLogForm); + setIsCareLogEditorOpen(false); + } + async function savePlant() { if (!form.nickname.trim() || !form.taxonId) { setError('Nickname and taxon are required.'); @@ -289,6 +401,9 @@ export function App() { setIsSaving(true); try { await deletePlant(plant.id); + if (selectedPlantId === plant.id) { + setSelectedPlantId(null); + } if (editingPlantId === plant.id) { cancelEditing(); } @@ -430,31 +545,151 @@ export function App() { } } - async function completeTask(task: CareTask) { - await logCare({ - plantId: task.plantId, - action: task.action, - notes: null, - performedOn: null, + async function saveFlagDefinition() { + if (!flagDefinitionForm.name.trim()) { + setError('Flag name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toFlagDefinitionPayload(flagDefinitionForm); + if (editingFlagDefinitionId === null) { + await createPlantFlag(payload); + } else { + await updatePlantFlag(editingFlagDefinitionId, payload); + } + cancelEditingFlagDefinition(); + await loadDashboard(); + } catch { + setError('Could not save the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function removeFlagDefinition(flag: PlantFlagDefinition) { + const confirmed = window.confirm(`Delete ${flag.name}? Flags assigned to plants will be disabled instead.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantFlag(flag.id); + if (editingFlagDefinitionId === flag.id) { + cancelEditingFlagDefinition(); + } + await loadDashboard(); + } catch { + await loadDashboard(); + setError('Could not delete the flag. If it is assigned to plants, it was disabled instead.'); + } finally { + setIsSaving(false); + } + } + + async function assignFlagToSelectedPlant() { + if (!selectedPlantId || !plantFlagForm.plantFlagDefinitionId) { + setError('Select a plant and flag first.'); + return; + } + + setIsSaving(true); + try { + await assignPlantFlag(selectedPlantId, toPlantFlagPayload(plantFlagForm)); + setPlantFlagForm(emptyPlantFlagForm); + await loadDashboard(); + } catch { + setError('Could not assign the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function resolveAssignedPlantFlag(flag: PlantFlag) { + if (!selectedPlantId) { + return; + } + + setIsSaving(true); + try { + await resolvePlantFlag(selectedPlantId, flag.id); + await loadDashboard(); + } catch { + setError('Could not resolve the plant flag.'); + } finally { + setIsSaving(false); + } + } + + async function removeAssignedPlantFlag(flag: PlantFlag) { + if (!selectedPlantId) { + return; + } + + const confirmed = window.confirm(`Remove ${flag.name} from this plant?`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await removePlantFlagAssignment(selectedPlantId, flag.id); + await loadDashboard(); + } catch { + setError('Could not remove the plant flag.'); + } finally { + setIsSaving(false); + } + } + + function completeTask(task: CareTask) { + const plant = plants.find((item) => item.id === task.plantId); + setEditingActionLogId(null); + setSelectedPlantId(task.plantId); + setCareLogForm({ + plantId: String(task.plantId), + careActionId: String(task.careActionId), + performedOn: getTodayInputDate(), + notes: '', + resources: [], }); - await loadDashboard(); + setIsCareLogEditorOpen(true); + setIsPlantEditorOpen(false); + if (plant && !plantMatchesSearch(plant, plantSearch)) { + setPlantSearch(''); + } + setView('plants'); } async function saveCareLog() { - if (!careLogForm.plantId || !careLogForm.action.trim()) { + if (!careLogForm.plantId || !careLogForm.careActionId) { setError('Plant and action are required to log care.'); return; } setIsSaving(true); try { - await logCare({ + const payload = { plantId: Number(careLogForm.plantId), - action: careLogForm.action.trim(), + careActionId: Number(careLogForm.careActionId), notes: careLogForm.notes.trim() || null, performedOn: careLogForm.performedOn || null, - }); - setCareLogForm(emptyCareLogForm); + resources: careLogForm.resources.map((resource) => ({ + actionResourceId: Number(resource.actionResourceId), + quantity: resource.quantity.trim() ? Number(resource.quantity) : null, + unit: resource.unit.trim() || null, + })), + }; + + if (editingActionLogId === null) { + await logCare(payload); + } else { + await updateActionLog(editingActionLogId, payload); + } + + cancelCareLog(); await loadDashboard(); } catch { setError('Could not log care.'); @@ -465,146 +700,215 @@ export function App() { return (
-
-
-

{getViewEyebrow(view)}

-

{getViewTitle(view)}

+
+
+ Plant-Man + Plant Care Supply
- + Search plants + setPlantSearch(event.target.value)} + /> + +
+ {plants.length} + plants tracked +
-
- {view === 'plants' ? ( - void removePlant(plant)} - onEdit={startEditingPlant} - onLogCare={() => void saveCareLog()} - onLogCareFieldChange={updateCareLogForm} - onFieldChange={updateForm} - onNew={startAddingPlant} - onQuickTaxonFieldChange={updateQuickTaxonForm} - onSave={() => void savePlant()} - onSaveQuickTaxon={() => void createAndSelectPlantTaxon()} - onSelectTaxon={selectPlantTaxon} - onStartLogCare={startLoggingCare} - onTaxonSearchChange={setPlantTaxonSearch} - quickTaxonForm={quickTaxonForm} - taxonSearch={plantTaxonSearch} - isCreatingTaxon={isCreatingPlantTaxon} - /> - ) : view === 'taxa' ? ( - void removeTaxon(taxon)} - onEdit={startEditingTaxon} - onFieldChange={updateTaxonForm} - onNew={startAddingTaxon} - onSave={() => void saveTaxon()} - /> - ) : view === 'actions' ? ( - void removeAction(action)} - onEdit={startEditingAction} - onFieldChange={updateActionForm} - onNew={startAddingAction} - onSave={() => void saveAction()} - /> - ) : view === 'resources' ? ( - void removeResource(resource)} - onEdit={startEditingResource} - onFieldChange={updateResourceForm} - onNew={startAddingResource} - onSave={() => void saveResource()} - resources={actionResources} - /> - ) : ( - void completeTask(task)} - onNewPlant={startAddingPlant} - /> - )} -
+
+ + +
+
+

{getViewEyebrow(view)}

+

{getViewTitle(view)}

+
+ +
+ {view === 'plants' ? ( + void removePlant(plant)} + onEdit={startEditingPlant} + onCompleteTask={completeTask} + onLogCare={() => void saveCareLog()} + onLogCareFieldChange={updateCareLogForm} + onFieldChange={updateForm} + onNew={startAddingPlant} + onOpenDetail={openPlantDetail} + onQuickTaxonFieldChange={updateQuickTaxonForm} + onPlantFlagFieldChange={updatePlantFlagForm} + onAssignFlag={() => void assignFlagToSelectedPlant()} + onResolveFlag={(flag) => void resolveAssignedPlantFlag(flag)} + onRemoveFlag={(flag) => void removeAssignedPlantFlag(flag)} + onSave={() => void savePlant()} + onSaveQuickTaxon={() => void createAndSelectPlantTaxon()} + onSelectTaxon={selectPlantTaxon} + onStartLogCare={startLoggingCare} + quickTaxonForm={quickTaxonForm} + isCreatingTaxon={isCreatingPlantTaxon} + /> + ) : view === 'taxa' ? ( + void removeTaxon(taxon)} + onEdit={startEditingTaxon} + onFieldChange={updateTaxonForm} + onNew={startAddingTaxon} + onSave={() => void saveTaxon()} + /> + ) : view === 'actions' ? ( + void removeAction(action)} + onEdit={startEditingAction} + onFieldChange={updateActionForm} + onNew={startAddingAction} + onSave={() => void saveAction()} + /> + ) : view === 'resources' ? ( + void removeResource(resource)} + onEdit={startEditingResource} + onFieldChange={updateResourceForm} + onNew={startAddingResource} + onSave={() => void saveResource()} + resources={actionResources} + /> + ) : view === 'flags' ? ( + void removeFlagDefinition(flag)} + onEdit={startEditingFlagDefinition} + onFieldChange={updateFlagDefinitionForm} + onNew={startAddingFlagDefinition} + onSave={() => void saveFlagDefinition()} + /> + ) : ( + + )} +
+
+
); } @@ -617,6 +921,7 @@ function getViewEyebrow(view: View) { return 'Reference'; case 'actions': case 'resources': + case 'flags': return 'Care setup'; default: return 'Today'; @@ -633,7 +938,68 @@ function getViewTitle(view: View) { return 'Care Actions'; case 'resources': return 'Resources'; + case 'flags': + return 'Plant Flags'; default: return 'Plant-Man'; } } + +function getDefaultPlantCareSchedules(careActions: CareAction[]): PlantCareScheduleFormState[] { + const waterAction = careActions.find( + (action) => action.isEnabled && action.name.toLowerCase() === 'water', + ); + + return waterAction + ? [{ + careActionId: String(waterAction.id), + everyDays: '7', + isEnabled: true, + }] + : []; +} + +function filterPlants(plants: Plant[], search: string) { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) { + return plants; + } + + return plants.filter((plant) => plantMatchesSearch(plant, normalizedSearch)); +} + +function filterCareTasks(tasks: CareTask[], visiblePlantIds: Set, search: string) { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) { + return tasks; + } + + return tasks.filter((task) => ( + visiblePlantIds.has(task.plantId) + || task.action.toLowerCase().includes(normalizedSearch) + || task.plantName.toLowerCase().includes(normalizedSearch) + || task.status.toLowerCase().includes(normalizedSearch) + )); +} + +function plantMatchesSearch(plant: Plant, search: string) { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) { + return true; + } + + return [ + plant.nickname, + plant.taxon, + plant.location, + plant.status, + plant.nextCare, + ...plant.flags.map((flag) => flag.name), + ...plant.flags.map((flag) => flag.category), + ...plant.careSchedules.map((schedule) => schedule.action), + ].some((value) => value.toLowerCase().includes(normalizedSearch)); +} + +function getTodayInputDate() { + return new Date().toISOString().slice(0, 10); +} diff --git a/plant-manager-web/src/api.ts b/plant-manager-web/src/api.ts index 7ccfda4..d935a07 100644 --- a/plant-manager-web/src/api.ts +++ b/plant-manager-web/src/api.ts @@ -7,7 +7,11 @@ import type { CareLogPayload, CareTask, Plant, + AssignPlantFlagPayload, PlantPayload, + PlantFlag, + PlantFlagDefinition, + PlantFlagDefinitionPayload, PlantTaxon, PlantTaxonPayload, } from './domain'; @@ -130,8 +134,51 @@ export async function deletePlant(id: number) { }); } +export async function getPlantFlags() { + return request('/api/plant-flags'); +} + +export async function createPlantFlag(payload: PlantFlagDefinitionPayload) { + return request('/api/plant-flags', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updatePlantFlag(id: number, payload: PlantFlagDefinitionPayload) { + return request(`/api/plant-flags/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export async function deletePlantFlag(id: number) { + return request(`/api/plant-flags/${id}`, { + method: 'DELETE', + }); +} + +export async function assignPlantFlag(plantId: number, payload: AssignPlantFlagPayload) { + return request(`/api/plants/${plantId}/flags`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function resolvePlantFlag(plantId: number, flagId: number) { + return request(`/api/plants/${plantId}/flags/${flagId}/resolve`, { + method: 'POST', + }); +} + +export async function removePlantFlagAssignment(plantId: number, flagId: number) { + return request(`/api/plants/${plantId}/flags/${flagId}`, { + method: 'DELETE', + }); +} + export async function getCareTasks() { - return request('/api/care-tasks/today'); + return request('/api/care-tasks/upcoming'); } export async function getActionLogs() { @@ -144,3 +191,19 @@ export async function logCare(payload: CareLogPayload) { body: JSON.stringify(payload), }); } + +export async function updateActionLog(id: number, payload: CareLogPayload) { + return request(`/api/action-logs/${id}`, { + method: 'PUT', + body: JSON.stringify({ + ...payload, + performedOn: payload.performedOn ?? new Date().toISOString().slice(0, 10), + }), + }); +} + +export async function deleteActionLog(id: number) { + return request(`/api/action-logs/${id}`, { + method: 'DELETE', + }); +} diff --git a/plant-manager-web/src/components/ActionsView.tsx b/plant-manager-web/src/components/ActionsView.tsx index 3dd4cd6..0546988 100644 --- a/plant-manager-web/src/components/ActionsView.tsx +++ b/plant-manager-web/src/components/ActionsView.tsx @@ -1,4 +1,4 @@ -import { Edit3, ListChecks, Plus, Save, Trash2, X } from 'lucide-react'; +import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; import type { CareAction } from '../domain'; import type { ActionFormState } from '../form-state'; @@ -7,6 +7,7 @@ type ActionsViewProps = { actions: CareAction[]; error: string | null; form: ActionFormState; + isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; onCancel: () => void; @@ -22,6 +23,7 @@ export function ActionsView({ actions, error, form, + isEditorOpen, isLoading, isSaving, onCancel, @@ -49,6 +51,7 @@ export function ActionsView({ + {isEditorOpen ? (
@@ -95,6 +98,7 @@ export function ActionsView({
+ ) : null}
@@ -108,9 +112,6 @@ export function ActionsView({ {actions.map((action) => (
-
- -

{action.name}

diff --git a/plant-manager-web/src/components/FlagsView.tsx b/plant-manager-web/src/components/FlagsView.tsx new file mode 100644 index 0000000..ef41622 --- /dev/null +++ b/plant-manager-web/src/components/FlagsView.tsx @@ -0,0 +1,154 @@ +import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; +import type { PlantFlagDefinition } from '../domain'; +import type { FlagDefinitionFormState } from '../form-state'; + +type FlagsViewProps = { + activeFlagName?: string; + error: string | null; + flags: PlantFlagDefinition[]; + form: FlagDefinitionFormState; + isEditorOpen: boolean; + isLoading: boolean; + isSaving: boolean; + onCancel: () => void; + onDelete: (flag: PlantFlagDefinition) => void; + onEdit: (flag: PlantFlagDefinition) => void; + onFieldChange: (field: keyof FlagDefinitionFormState, value: string | boolean) => void; + onNew: () => void; + onSave: () => void; +}; + +export function FlagsView({ + activeFlagName, + error, + flags, + form, + isEditorOpen, + isLoading, + isSaving, + onCancel, + onDelete, + onEdit, + onFieldChange, + onNew, + onSave, +}: FlagsViewProps) { + const enabledCount = flags.filter((flag) => flag.isEnabled).length; + + return ( + <> +

+
+

Plant flags

+

+ {isLoading ? 'Loading flags' : `${enabledCount} flags enabled`} +

+

{error ?? 'Configure reusable pest, condition, and workflow flags for plants.'}

+
+ +
+ + {isEditorOpen ? ( +
+
+
+

{activeFlagName ? 'Editing' : 'New flag'}

+

{activeFlagName ?? 'Flag details'}

+
+ +
+ +
+ + + + +
+ +
+ + +
+
+ ) : null} + +
+
+

All Flags

+
+ +
+ {!isLoading && flags.length === 0 ? ( +

No flags yet.

+ ) : null} + + {flags.map((flag) => ( +
+
+

{flag.name}

+

+ {flag.category} + {' - '} + {flag.isEnabled ? 'Enabled' : 'Disabled'} +

+
+
+ + {flag.name} + + + +
+
+ ))} +
+
+ + ); +} diff --git a/plant-manager-web/src/components/HomeView.tsx b/plant-manager-web/src/components/HomeView.tsx index f34b9d0..87d2a12 100644 --- a/plant-manager-web/src/components/HomeView.tsx +++ b/plant-manager-web/src/components/HomeView.tsx @@ -7,9 +7,12 @@ type HomeViewProps = { dueCount: number; error: string | null; isLoading: boolean; + isPlantSearchActive: boolean; plants: Plant[]; + totalPlantCount: number; onCompleteTask: (task: CareTask) => void; onNewPlant: () => void; + onOpenPlant: (plant: Plant) => void; }; export function HomeView({ @@ -17,9 +20,12 @@ export function HomeView({ dueCount, error, isLoading, + isPlantSearchActive, plants, + totalPlantCount, onCompleteTask, onNewPlant, + onOpenPlant, }: HomeViewProps) { return ( <> @@ -45,7 +51,9 @@ export function HomeView({
{!isLoading && careTasks.length === 0 ? ( -

No care tasks yet.

+

+ {isPlantSearchActive ? 'No care tasks match the search.' : 'No care tasks yet.'} +

) : null} {careTasks.map((task) => ( @@ -61,7 +69,7 @@ export function HomeView({ onClick={() => onCompleteTask(task)} > - Done + Log
))} @@ -77,12 +85,16 @@ export function HomeView({
- {!isLoading && plants.length === 0 ? ( + {!isLoading && totalPlantCount === 0 ? (

No plants yet.

) : null} + {!isLoading && totalPlantCount > 0 && plants.length === 0 ? ( +

No plants match the search.

+ ) : null} + {plants.map((plant) => ( - + ))}
diff --git a/plant-manager-web/src/components/PlantCard.tsx b/plant-manager-web/src/components/PlantCard.tsx index e08bd89..cd47ac8 100644 --- a/plant-manager-web/src/components/PlantCard.tsx +++ b/plant-manager-web/src/components/PlantCard.tsx @@ -1,35 +1,61 @@ -import { Leaf } from 'lucide-react'; +import { Eye } from 'lucide-react'; import type { CareStatus, Plant } from '../domain'; const statusLabel: Record = { due: 'Due', soon: 'Soon', ok: 'Ok', + unscheduled: 'Unscheduled', }; -export function PlantCard({ plant }: { plant: Plant }) { +type PlantCardProps = { + plant: Plant; + onOpen?: (plant: Plant) => void; +}; + +export function PlantCard({ plant, onOpen }: PlantCardProps) { return (
-
-
- -
- - {statusLabel[plant.status]} - -

{plant.nickname}

-

{plant.taxon}

+
+

{plant.taxon}

+ {plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? ( +
+ {plant.flags + .filter((flag) => flag.resolvedOn === null) + .map((flag) => ( + + {flag.name} + + ))} +
+ ) : null} +
Location
{plant.location}
-
Last watered
-
{plant.lastWatered}
+
Next care
+
{plant.nextCare}
+
+ + {statusLabel[plant.status]} + + {onOpen ? ( + + ) : null} +
); } diff --git a/plant-manager-web/src/components/PlantsView.tsx b/plant-manager-web/src/components/PlantsView.tsx index 820690e..7c2ebf7 100644 --- a/plant-manager-web/src/components/PlantsView.tsx +++ b/plant-manager-web/src/components/PlantsView.tsx @@ -1,80 +1,134 @@ -import { Check, Edit3, Leaf, Plus, Save, Trash2, X, ClipboardCheck } from 'lucide-react'; -import type { ActionLog, CareAction, Plant, PlantTaxon } from '../domain'; -import type { CareLogFormState, PlantFormState, TaxonFormState } from '../form-state'; +import { + CalendarClock, + ClipboardCheck, + Edit3, + Eye, + Plus, + Save, + Trash2, + X, +} from 'lucide-react'; +import type { + ActionResource, + CareAction, + CareStatus, + CareTask, + Plant, + PlantFlag, + PlantFlagDefinition, + PlantTaxon, +} from '../domain'; +import type { + CareLogFormState, + CareLogResourceFormState, + PlantCareScheduleFormState, + PlantFlagFormState, + PlantFormState, + TaxonFormState, +} from '../form-state'; import { formatTaxon } from '../form-state'; type PlantsViewProps = { activePlantName?: string; - actionLogs: ActionLog[]; + actionResources: ActionResource[]; careActions: CareAction[]; + careTasks: CareTask[]; careLogForm: CareLogFormState; + editingActionLogId: number | null; error: string | null; form: PlantFormState; + isCareLogEditorOpen: boolean; isLoading: boolean; + isPlantEditorOpen: boolean; isSaving: boolean; + plantFlagDefinitions: PlantFlagDefinition[]; + plantFlagForm: PlantFlagFormState; plantTaxa: PlantTaxon[]; plants: Plant[]; + selectedPlant?: Plant; + visiblePlants: Plant[]; onCancel: () => void; + onCancelCareLog: () => void; + onClosePlantDetail: () => void; + onCompleteTask: (task: CareTask) => void; onDelete: (plant: Plant) => void; onEdit: (plant: Plant) => void; - onFieldChange: (field: keyof PlantFormState, value: string) => void; + onFieldChange: (field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) => void; onLogCare: () => void; - onLogCareFieldChange: (field: keyof CareLogFormState, value: string) => void; + onLogCareFieldChange: ( + field: keyof CareLogFormState, + value: string | CareLogResourceFormState[], + ) => void; onNew: () => void; + onOpenDetail: (plant: Plant) => void; onQuickTaxonFieldChange: (field: keyof TaxonFormState, value: string) => void; + onPlantFlagFieldChange: (field: keyof PlantFlagFormState, value: string) => void; + onAssignFlag: () => void; + onResolveFlag: (flag: PlantFlag) => void; + onRemoveFlag: (flag: PlantFlag) => void; onSave: () => void; onSaveQuickTaxon: () => void; onSelectTaxon: (taxon: PlantTaxon) => void; onStartLogCare: (plant?: Plant) => void; - onTaxonSearchChange: (value: string) => void; quickTaxonForm: TaxonFormState; - taxonSearch: string; isCreatingTaxon: boolean; }; export function PlantsView({ activePlantName, - actionLogs, + actionResources, careActions, + careTasks, careLogForm, + editingActionLogId, error, form, + isCareLogEditorOpen, isLoading, + isPlantEditorOpen, isSaving, + plantFlagDefinitions, + plantFlagForm, plantTaxa, plants, + selectedPlant, + visiblePlants, onCancel, + onCancelCareLog, + onClosePlantDetail, + onCompleteTask, onDelete, onEdit, onFieldChange, onLogCare, onLogCareFieldChange, onNew, + onOpenDetail, onQuickTaxonFieldChange, + onPlantFlagFieldChange, + onAssignFlag, + onResolveFlag, + onRemoveFlag, onSave, onSaveQuickTaxon, onSelectTaxon, onStartLogCare, - onTaxonSearchChange, quickTaxonForm, - taxonSearch, isCreatingTaxon, }: PlantsViewProps) { const enabledCareActions = careActions.filter((action) => action.isEnabled); - const selectedTaxon = plantTaxa.find((taxon) => String(taxon.id) === form.taxonId); - const normalizedSearch = taxonSearch.trim().toLowerCase(); - const filteredTaxa = plantTaxa.filter((taxon) => { - if (!normalizedSearch) { - return true; - } - - return [ - taxon.name, - taxon.genus, - taxon.species, - formatTaxon(taxon), - ].some((value) => value.toLowerCase().includes(normalizedSearch)); - }); + const enabledActionResources = actionResources.filter((resource) => resource.isEnabled); + const selectedPlantTasks = selectedPlant + ? careTasks.filter((task) => task.plantId === selectedPlant.id) + : []; + const selectedPlantTaxon = selectedPlant + ? plantTaxa.find((taxon) => taxon.id === selectedPlant.taxonId) + : undefined; + const selectedPlantSchedules = + selectedPlant?.careSchedules.filter((schedule) => schedule.isEnabled) ?? []; + const enabledFlagDefinitions = plantFlagDefinitions.filter((flag) => flag.isEnabled); + const selectedPlantActiveFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? []; + const selectedPlantResolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? []; return ( <> @@ -86,20 +140,261 @@ export function PlantsView({

{error ?? 'Add, update, or remove plants from your collection.'}

- +
+ + +
+ {selectedPlant ? ( +
+
+
+

Plant focus

+

{selectedPlant.nickname}

+
+
+ + + +
+
+ +
+
+ Taxon + {selectedPlantTaxon ? formatTaxon(selectedPlantTaxon) : selectedPlant.taxon} +
+
+ Location + {selectedPlant.location || 'No location'} +
+
+ Next care + {selectedPlant.nextCare} +
+
+ Active flags + {selectedPlantActiveFlags.length === 0 ? ( + None + ) : ( +
+ {selectedPlantActiveFlags.map((flag) => ( + + {flag.name} + + ))} +
+ )} +
+
+ +
+
+
+ +

Schedules

+
+ {selectedPlantSchedules.length === 0 ? ( +

No schedules enabled.

+ ) : ( +
+ {selectedPlantSchedules.map((schedule) => ( +
+
+

{schedule.action}

+

+ Every {schedule.everyDays} days - Last {schedule.lastPerformed} +

+
+ + {statusLabel[schedule.status]} + +
+ ))} +
+ )} +
+ +
+
+ +

Next Tasks

+
+ {selectedPlantTasks.length === 0 ? ( +

No upcoming tasks.

+ ) : ( +
+ {selectedPlantTasks.map((task) => ( +
+
+

{task.action}

+

{task.due}

+
+
+ + {statusLabel[task.status]} + + +
+
+ ))} +
+ )} +
+ +
+
+ +

Plant Flags

+
+ +
+ + + + + +
+ + {selectedPlantActiveFlags.length === 0 ? ( +

No active flags.

+ ) : ( +
+ {selectedPlantActiveFlags.map((flag) => ( +
+
+

+ + {flag.name} + +

+

+ {flag.category} - {flag.severity} - Started {formatDate(flag.startedOn)} + {flag.notes ? ` - ${flag.notes}` : ''} +

+
+
+ + +
+
+ ))} +
+ )} + + {selectedPlantResolvedFlags.length > 0 ? ( +
+ {selectedPlantResolvedFlags.slice(0, 4).map((flag) => ( +
+
+

{flag.name}

+

+ Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''} + {flag.notes ? ` - ${flag.notes}` : ''} +

+
+
+ ))} +
+ ) : null} +
+ +
+
+ ) : null} + + {isCareLogEditorOpen ? (

Manual entry

-

Log care

+

{editingActionLogId === null ? 'Log care' : 'Edit care log'}

-
@@ -121,14 +416,15 @@ export function PlantsView({ + {selectedResource ? ( +
+ + +
+ ) : null} + + ); + })} +
+ {editingActionLogId === null ? null : ( + + )}
+ ) : null} -
-
-

Care History

-
- -
- {!isLoading && actionLogs.length === 0 ? ( -

No care logged yet.

- ) : null} - - {actionLogs.slice(0, 12).map((log) => ( -
-
- -
-
-

{log.action}

-

- {log.plantName} - {formatLogDate(log.performedOn)} - {log.notes ? ` - ${log.notes}` : ''} -

-
-
- ))} -
-
- + {isPlantEditorOpen ? (
@@ -208,34 +548,26 @@ export function PlantsView({
- {selectedTaxon ? ( -

- - {formatTaxon(selectedTaxon)} -

- ) : null} -
- {filteredTaxa.length === 0 ? ( -

No matching taxa.

- ) : null} - {filteredTaxa.slice(0, 8).map((taxon) => ( - - ))} -
- - +
+ Care schedules + {enabledCareActions.length === 0 ? ( +

No enabled actions.

+ ) : null} + {enabledCareActions.map((action) => { + const actionId = String(action.id); + const selectedSchedule = form.careSchedules.find( + (schedule) => schedule.careActionId === actionId, + ); + + return ( +
+ + {selectedSchedule?.isEnabled ? ( +
+ +
+ ) : null} +
+ ); + })} +
@@ -308,6 +676,7 @@ export function PlantsView({
+ ) : null}
@@ -319,16 +688,31 @@ export function PlantsView({

No plants yet.

) : null} - {plants.map((plant) => ( + {!isLoading && plants.length > 0 && visiblePlants.length === 0 ? ( +

No plants match the search.

+ ) : null} + + {visiblePlants.map((plant) => (
-
- -

{plant.nickname}

{plant.taxon} - {plant.location}

+ {plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? ( +
+ {plant.flags + .filter((flag) => flag.resolvedOn === null) + .map((flag) => ( + + {flag.name} + + ))} +
+ ) : null}
+ @@ -347,7 +731,14 @@ export function PlantsView({ ); } -function formatLogDate(date: string) { +const statusLabel: Record = { + due: 'Due', + soon: 'Soon', + ok: 'Ok', + unscheduled: 'Unscheduled', +}; + +function formatDate(date: string) { const [year, month, day] = date.split('-'); if (!year || !month || !day) { return date; diff --git a/plant-manager-web/src/components/ResourcesView.tsx b/plant-manager-web/src/components/ResourcesView.tsx index ab365ed..3807b93 100644 --- a/plant-manager-web/src/components/ResourcesView.tsx +++ b/plant-manager-web/src/components/ResourcesView.tsx @@ -1,4 +1,4 @@ -import { Edit3, Package, Plus, Save, Trash2, X } from 'lucide-react'; +import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; import type { ActionResource } from '../domain'; import type { ResourceFormState } from '../form-state'; @@ -6,6 +6,7 @@ type ResourcesViewProps = { activeResourceName?: string; error: string | null; form: ResourceFormState; + isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; onCancel: () => void; @@ -21,6 +22,7 @@ export function ResourcesView({ activeResourceName, error, form, + isEditorOpen, isLoading, isSaving, onCancel, @@ -49,6 +51,7 @@ export function ResourcesView({
+ {isEditorOpen ? (
@@ -102,6 +105,7 @@ export function ResourcesView({
+ ) : null}
@@ -115,9 +119,6 @@ export function ResourcesView({ {resources.map((resource) => (
-
- -

{resource.name}

diff --git a/plant-manager-web/src/components/TaxaView.tsx b/plant-manager-web/src/components/TaxaView.tsx index c8fbd02..eae8839 100644 --- a/plant-manager-web/src/components/TaxaView.tsx +++ b/plant-manager-web/src/components/TaxaView.tsx @@ -1,4 +1,4 @@ -import { BookOpen, Edit3, Plus, Save, Trash2, X } from 'lucide-react'; +import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; import type { PlantTaxon } from '../domain'; import type { TaxonFormState } from '../form-state'; import { formatTaxon } from '../form-state'; @@ -7,6 +7,7 @@ type TaxaViewProps = { activeTaxonName?: string; error: string | null; form: TaxonFormState; + isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; taxa: PlantTaxon[]; @@ -22,6 +23,7 @@ export function TaxaView({ activeTaxonName, error, form, + isEditorOpen, isLoading, isSaving, taxa, @@ -48,6 +50,7 @@ export function TaxaView({

+ {isEditorOpen ? (
@@ -114,6 +117,7 @@ export function TaxaView({
+ ) : null}
@@ -127,9 +131,6 @@ export function TaxaView({ {taxa.map((taxon) => (
-
- -

{taxon.name}

{formatTaxon(taxon)}

diff --git a/plant-manager-web/src/domain.ts b/plant-manager-web/src/domain.ts index a69aac0..edc0284 100644 --- a/plant-manager-web/src/domain.ts +++ b/plant-manager-web/src/domain.ts @@ -1,4 +1,4 @@ -export type CareStatus = 'due' | 'soon' | 'ok'; +export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled'; export type Plant = { id: number; @@ -6,11 +6,22 @@ export type Plant = { taxonId: number; taxon: string; location: string; - lastWateredOn: string | null; - lastWatered: string; nextCare: string; - waterEveryDays: number; status: CareStatus; + flags: PlantFlag[]; + careSchedules: PlantCareSchedule[]; +}; + +export type PlantCareSchedule = { + id: number; + careActionId: number; + action: string; + everyDays: number; + lastPerformedOn: string | null; + lastPerformed: string; + nextCare: string; + status: CareStatus; + isEnabled: boolean; }; export type PlantTaxon = { @@ -38,12 +49,45 @@ export type ActionResource = { isEnabled: boolean; }; +export type PlantFlagDefinition = { + id: number; + name: string; + category: string; + color: string; + isEnabled: boolean; +}; + +export type PlantFlag = { + id: number; + plantFlagDefinitionId: number; + name: string; + category: string; + color: string; + severity: 'low' | 'medium' | 'high'; + startedOn: string; + resolvedOn: string | null; + notes: string | null; +}; + +export type ActionLogResource = { + actionResourceId: number; + name: string; + category: string | null; + quantity: number | null; + unit: string | null; +}; + export type PlantPayload = { nickname: string; taxonId: number; location: string; - lastWateredOn: string | null; - waterEveryDays: number; + careSchedules: PlantCareSchedulePayload[]; +}; + +export type PlantCareSchedulePayload = { + careActionId: number; + everyDays: number; + isEnabled: boolean; }; export type PlantTaxonPayload = { @@ -68,27 +112,51 @@ export type ActionResourcePayload = { isEnabled: boolean; }; +export type PlantFlagDefinitionPayload = { + name: string; + category: string | null; + color: string | null; + isEnabled: boolean; +}; + +export type AssignPlantFlagPayload = { + plantFlagDefinitionId: number; + severity: string | null; + startedOn: string | null; + notes: string | null; +}; + export type CareLogPayload = { plantId: number; - action: string; + careActionId: number; notes: string | null; performedOn: string | null; + resources: CareLogResourcePayload[]; +}; + +export type CareLogResourcePayload = { + actionResourceId: number; + quantity: number | null; + unit: string | null; }; export type ActionLog = { id: number; plantId: number; plantName: string; + careActionId: number; action: string; notes: string | null; performedOn: string; + resources: ActionLogResource[]; }; export type CareTask = { id: number; plantId: number; plantName: string; - action: 'Water' | 'Fertilize' | 'Prune' | 'Inspect'; + careActionId: number; + action: string; due: string; status: CareStatus; }; diff --git a/plant-manager-web/src/form-state.ts b/plant-manager-web/src/form-state.ts index 75c88fa..0ecf6ad 100644 --- a/plant-manager-web/src/form-state.ts +++ b/plant-manager-web/src/form-state.ts @@ -4,7 +4,10 @@ import type { CareAction, CareActionPayload, Plant, + AssignPlantFlagPayload, PlantPayload, + PlantFlagDefinition, + PlantFlagDefinitionPayload, PlantTaxon, PlantTaxonPayload, } from './domain'; @@ -13,8 +16,13 @@ export const emptyPlantForm = { nickname: '', taxonId: '', location: '', - lastWateredOn: '', - waterEveryDays: '7', + careSchedules: [] as PlantCareScheduleFormState[], +}; + +export type PlantCareScheduleFormState = { + careActionId: string; + everyDays: string; + isEnabled: boolean; }; export const emptyTaxonForm = { @@ -39,27 +47,53 @@ export const emptyResourceForm = { isEnabled: true, }; +export const emptyFlagDefinitionForm = { + name: '', + category: 'Pest', + color: '#f2f2f2', + isEnabled: true, +}; + +export const emptyPlantFlagForm = { + plantFlagDefinitionId: '', + severity: 'medium', + startedOn: '', + notes: '', +}; + +export type CareLogResourceFormState = { + actionResourceId: string; + quantity: string; + unit: string; +}; + export const emptyCareLogForm = { plantId: '', - action: 'Water', + careActionId: '', performedOn: '', notes: '', + resources: [] as CareLogResourceFormState[], }; export type PlantFormState = typeof emptyPlantForm; export type TaxonFormState = typeof emptyTaxonForm; export type ActionFormState = typeof emptyActionForm; export type ResourceFormState = typeof emptyResourceForm; +export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm; +export type PlantFlagFormState = typeof emptyPlantFlagForm; export type CareLogFormState = typeof emptyCareLogForm; -export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources'; +export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources' | 'flags'; export function toPlantForm(plant: Plant): PlantFormState { return { nickname: plant.nickname, taxonId: String(plant.taxonId), location: plant.location, - lastWateredOn: plant.lastWateredOn ?? '', - waterEveryDays: String(plant.waterEveryDays), + careSchedules: plant.careSchedules.map((schedule) => ({ + careActionId: String(schedule.careActionId), + everyDays: String(schedule.everyDays), + isEnabled: schedule.isEnabled, + })), }; } @@ -68,8 +102,11 @@ export function toPlantPayload(form: PlantFormState): PlantPayload { nickname: form.nickname.trim(), taxonId: Number(form.taxonId), location: form.location.trim(), - lastWateredOn: form.lastWateredOn || null, - waterEveryDays: Number(form.waterEveryDays), + careSchedules: form.careSchedules.map((schedule) => ({ + careActionId: Number(schedule.careActionId), + everyDays: Number(schedule.everyDays), + isEnabled: schedule.isEnabled, + })), }; } @@ -129,6 +166,33 @@ export function toResourcePayload(form: ResourceFormState): ActionResourcePayloa }; } +export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState { + return { + name: flag.name, + category: flag.category, + color: flag.color, + isEnabled: flag.isEnabled, + }; +} + +export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFlagDefinitionPayload { + return { + name: form.name.trim(), + category: form.category.trim() || null, + color: form.color.trim() || null, + isEnabled: form.isEnabled, + }; +} + +export function toPlantFlagPayload(form: PlantFlagFormState): AssignPlantFlagPayload { + return { + plantFlagDefinitionId: Number(form.plantFlagDefinitionId), + severity: form.severity || null, + startedOn: form.startedOn || null, + notes: form.notes.trim() || null, + }; +} + 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 0cd6eb8..0967a0a 100644 --- a/plant-manager-web/src/styles.css +++ b/plant-manager-web/src/styles.css @@ -1,19 +1,21 @@ :root { - color: #17211b; - background: #f7faf6; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; + color: #111; + background: #fff; + font-family: Arial, Helvetica, sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; - --border: #dfe8dc; - --muted: #64746a; - --panel: #ffffff; - --accent: #276749; - --accent-strong: #154734; - --warning: #b7791f; - --danger: #b83232; + --border: #a7a7a7; + --rule: #8f8f8f; + --muted: #555; + --panel: #fff; + --side: #f7f7f7; + --row: #f7f7f7; + --hover: #f1f5fb; + --link: #0645ad; + --warning: #8a5a00; + --danger: #b00000; + --ok: #126126; } * { @@ -24,8 +26,15 @@ body { margin: 0; min-width: 320px; min-height: 100vh; - background: - linear-gradient(180deg, #f7faf6 0%, #eef5ee 100%); + overflow-x: hidden; + background: #fff; + font-size: 13px; +} + +button, +input, +select { + font: inherit; } button, @@ -34,52 +43,32 @@ a { } button { - font: inherit; + cursor: pointer; } button:disabled { cursor: not-allowed; - opacity: 0.7; + opacity: 0.65; } -.app-shell { - min-height: 100vh; - padding: 18px 16px 84px; -} - -.top-bar, -.section-heading, -.plant-card-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.top-bar { - margin: 0 auto 18px; - max-width: 1040px; -} - -.eyebrow { - margin: 0 0 4px; - color: var(--muted); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid #111; + outline-offset: 1px; } h1, h2, h3, +h4, p { margin-top: 0; } h1 { margin-bottom: 0; - font-size: 1.75rem; + font-size: 1.55rem; line-height: 1.1; } @@ -88,16 +77,213 @@ h2 { font-size: 1.05rem; } -h3 { - margin-bottom: 4px; - font-size: 0.97rem; +h3, +h4 { + margin: 0 0 2px; + font-size: 0.95rem; + line-height: 1.2; +} + +p { + margin-bottom: 0; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-shell { + min-height: 100vh; + padding: 0; +} + +.catalog-header { + position: sticky; + top: 0; + z-index: 10; + display: grid; + grid-template-columns: minmax(190px, 260px) minmax(260px, 680px) minmax(120px, 1fr); + align-items: center; + gap: 18px; + min-height: 74px; + padding: 10px 22px; + border-bottom: 1px solid var(--rule); + background: #fff; + box-shadow: 0 1px 0 #e8e8e8 inset, 0 1px 3px rgb(0 0 0 / 8%); +} + +.catalog-brand { + display: grid; + gap: 2px; +} + +.catalog-logo { + color: #111; + font-size: 1.65rem; + font-weight: 800; + line-height: 1; +} + +.catalog-subtitle, +.catalog-contact span { + color: #555; + font-size: 0.78rem; + font-weight: 700; +} + +.catalog-contact { + display: grid; + justify-items: end; + color: #111; + white-space: nowrap; +} + +.catalog-contact strong { + line-height: 1; + font-size: 1rem; +} + +.search-field { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: center; + gap: 8px; + width: 100%; + max-width: 680px; + min-height: 36px; + padding: 0 12px; + border: 1px solid #777; + background: #fff; + color: var(--link); +} + +.search-field input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #111; + font-size: 0.95rem; +} + +.search-field input::placeholder { + color: var(--muted); +} + +.catalog-layout { + display: grid; + grid-template-columns: 228px minmax(0, 1fr); + align-items: start; + width: 100%; + max-width: 1280px; + margin: 0 auto; + border-right: 1px solid var(--border); + border-left: 1px solid var(--border); +} + +.catalog-sidebar { + position: sticky; + top: 74px; + min-width: 0; + min-height: calc(100vh - 74px); + padding: 16px 16px 24px 22px; + border-right: 1px solid var(--rule); + background: var(--side); +} + +.catalog-sidebar h2, +.catalog-help h3 { + margin: 0 0 8px; + color: #111; + font-size: 0.95rem; +} + +.catalog-nav { + display: grid; + min-width: 0; + gap: 2px; +} + +.catalog-nav button, +.text-button { + min-width: 0; + min-height: 0; + padding: 3px 0; + border: 0; + background: transparent; + color: var(--link); + font-size: 0.9rem; + font-weight: 400; + text-align: left; + text-decoration: underline; + overflow-wrap: anywhere; +} + +.catalog-nav button:hover, +.text-button:hover { + color: #002f7a; +} + +.catalog-nav button[aria-current='page'] { + color: #111; + font-weight: 700; + text-decoration: none; +} + +.catalog-help { + margin-top: 22px; + padding-top: 12px; + border-top: 1px solid #cfcfcf; + color: #333; + font-size: 0.86rem; +} + +.catalog-help p { + margin: 0 0 4px; +} + +.catalog-main { + min-width: 0; + padding: 16px 22px 36px; + background: #fff; +} + +.catalog-page-title { + display: flex; + align-items: end; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; + border-bottom: 2px solid #111; +} + +.catalog-page-title h1 { + margin: 0 0 6px; + font-weight: 700; } .content { display: grid; - gap: 22px; - margin: 0 auto; - max-width: 1040px; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + max-width: none; + margin: 0; +} + +.eyebrow { + margin: 0 0 3px; + color: #555; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; } .summary-panel, @@ -106,198 +292,348 @@ h3 { .plant-row, .task-row { border: 1px solid var(--border); - border-radius: 8px; + border-radius: 0; background: var(--panel); - box-shadow: 0 10px 24px rgb(25 46 31 / 7%); + box-shadow: none; } .summary-panel { display: grid; - gap: 18px; - padding: 18px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-top: 3px solid var(--rule); } .summary-panel h2 { - margin-bottom: 6px; - font-size: 1.65rem; + margin-bottom: 3px; + font-size: 1.25rem; } .summary-panel p { - margin-bottom: 0; - color: var(--muted); + max-width: 68ch; + color: #444; + font-size: 0.86rem; +} + +.section, +.editor-panel { + display: grid; + gap: 10px; +} + +.editor-panel { + padding: 10px 12px; +} + +.section-heading, +.plant-card-top { + display: flex; + align-items: end; + justify-content: space-between; + gap: 10px; + min-height: 28px; + padding-bottom: 3px; + border-bottom: 1px solid #111; } .primary-action, .small-action, -.icon-button, -.text-button { - border: 0; - cursor: pointer; -} - -.primary-action, -.small-action { +.icon-button { display: inline-flex; + flex-shrink: 0; align-items: center; justify-content: center; gap: 8px; - border-radius: 8px; + border: 1px solid #777; + border-radius: 0; + background: #f2f2f2; + color: #111; + box-shadow: inset 0 1px 0 #fff; font-weight: 700; } .primary-action { - min-height: 46px; - padding: 0 16px; - background: var(--accent); - color: white; + min-height: 34px; + padding: 0 12px; } .small-action { - min-width: 88px; - min-height: 38px; - padding: 0 12px; - background: #edf7f1; - color: var(--accent-strong); + min-width: 62px; + min-height: 28px; + padding: 0 8px; } .icon-button { display: inline-grid; - min-width: 44px; - min-height: 44px; + min-width: 30px; + min-height: 30px; place-items: center; - border-radius: 8px; - background: white; - color: var(--accent-strong); - box-shadow: inset 0 0 0 1px var(--border); } .icon-button.compact { - min-width: 38px; - min-height: 38px; + min-width: 28px; + min-height: 28px; } .icon-button.danger { color: var(--danger); } -.text-button { - min-height: 38px; - padding: 0 2px; - background: transparent; - color: var(--accent); - font-weight: 700; +.primary-action:hover:not(:disabled), +.small-action:hover:not(:disabled), +.icon-button:hover:not(:disabled) { + background: #e5e5e5; } -.section { - display: grid; - gap: 12px; +.plant-mark svg, +.icon-button svg, +.small-action svg, +.primary-action svg, +.detail-section-heading svg { + width: 15px; + height: 15px; } -.task-list { +.task-list, +.plant-list, +.plant-grid, +.detail-list { display: grid; - gap: 10px; + gap: 0; + border-top: 1px solid var(--border); + background: #fff; +} + +.task-row, +.plant-row, +.plant-card, +.detail-row { + min-height: 42px; + border: 0; + border-bottom: 1px solid var(--border); + background: #fff; +} + +.task-row:nth-child(even), +.plant-row:nth-child(even), +.plant-card:nth-child(even), +.detail-row:nth-child(even) { + background: var(--row); +} + +.task-row:hover, +.plant-row:hover, +.plant-card:hover, +.detail-row:hover { + background: var(--hover); } .task-row { display: grid; - grid-template-columns: 10px 1fr auto; + grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 12px; - padding: 13px; + padding: 7px 8px; } +.plant-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 7px 8px; +} + +.plant-card { + display: grid; + grid-template-columns: + minmax(130px, 1fr) + minmax(170px, 1.15fr) + minmax(210px, 1.35fr) + max-content; + align-items: center; + column-gap: 12px; + row-gap: 4px; + padding: 7px 8px; +} + +.plant-card > h3 { + grid-column: 1; +} + +.plant-card .taxon { + grid-column: 2; + min-width: 0; +} + +.plant-card .taxon p, +.plant-row p, .task-row p, +.detail-row p, .taxon, .empty-state { - margin-bottom: 0; - color: var(--muted); - font-size: 0.9rem; + color: #444; + font-size: 0.86rem; + line-height: 1.25; +} + +.plant-card dl { + grid-column: 3; + display: grid; + grid-template-columns: minmax(82px, 1fr) minmax(92px, 1fr); + align-items: center; + gap: 12px; + min-width: 0; + margin: 0; +} + +.plant-card-actions { + grid-column: 4; + display: inline-flex; + align-items: center; + justify-self: end; + gap: 6px; + white-space: nowrap; +} + +dt { + color: #555; + font-size: 0.72rem; + font-weight: 700; + line-height: 1; +} + +dd { + margin: 3px 0 0; + font-size: 0.84rem; + font-weight: 700; + line-height: 1.15; + overflow-wrap: anywhere; +} + +.row-actions, +.form-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; } .empty-state { - padding: 12px 2px; + padding: 8px; + border-bottom: 1px solid var(--border); + background: #fafafa; } .status-dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -.status-dot.due, -.status-pill.due { - background: #fff1f1; - color: var(--danger); + width: 8px; + height: 8px; + border-radius: 0; } .status-dot.due { background: var(--danger); } -.status-dot.soon, -.status-pill.soon { - background: #fff7e8; - color: var(--warning); -} - .status-dot.soon { background: var(--warning); } -.status-dot.ok, -.status-pill.ok { - background: #edf7f1; - color: var(--accent); -} - .status-dot.ok { - background: var(--accent); + background: var(--ok); } -.plant-grid { +.status-pill { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 0 6px; + border: 1px solid currentColor; + border-radius: 0; + background: #fff; + font-size: 0.72rem; + font-weight: 700; +} + +.status-pill.due { + background: #fff0f0; + color: var(--danger); +} + +.status-pill.soon { + background: #fff8d6; + color: var(--warning); +} + +.status-pill.ok { + background: #eef9ee; + color: var(--ok); +} + +.status-pill.unscheduled { + background: #eee; + color: #555; +} + +.plant-form, +.quick-taxon-fields { display: grid; - gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + gap: 9px; } -.plant-card { - padding: 14px; -} - -.editor-panel { +.plant-form label, +.quick-taxon label, +.flag-assignment-form label { display: grid; - gap: 16px; - padding: 16px; -} - -.plant-form { - display: grid; - gap: 12px; -} - -.plant-form label { - display: grid; - gap: 6px; - color: var(--muted); + gap: 4px; + color: #333; font-size: 0.78rem; font-weight: 800; } .plant-form input, -.plant-form select { +.plant-form select, +.quick-taxon input, +.flag-assignment-form input, +.flag-assignment-form select { width: 100%; - min-height: 42px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: white; - color: #17211b; - font: inherit; + min-height: 30px; + padding: 0 7px; + border: 1px solid #888; + border-radius: 0; + background: #fff; + color: #111; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%); } -.plant-form input:focus, -.plant-form select:focus { - border-color: var(--accent); - outline: 2px solid rgb(39 103 73 / 18%); +.plant-form input[type='color'] { + padding: 2px; +} + +.plant-form .toggle-field, +.check-option { + display: flex; + min-height: 30px; + align-items: center; + gap: 7px; +} + +.plant-form .toggle-field { + align-self: end; + padding: 5px 7px; + border: 1px solid #888; + background: #f7f7f7; +} + +.plant-form .toggle-field input, +.check-option input { + width: 15px; + min-height: 15px; + accent-color: #111; } .taxon-picker { @@ -305,252 +641,276 @@ h3 { gap: 8px; } -.selected-taxon { - display: inline-flex; - align-items: center; +.resource-picker { + display: grid; + grid-column: 1 / -1; gap: 6px; + min-width: 0; margin: 0; - color: var(--accent-strong); - font-size: 0.84rem; - font-weight: 800; -} - -.taxon-results { - display: grid; - max-height: 238px; - overflow: auto; + padding: 8px; border: 1px solid var(--border); - border-radius: 8px; - background: #fbfdfb; + background: #fff; } -.taxon-option { +.resource-picker legend { + padding: 0 4px; + color: #111; + font-size: 0.82rem; + font-weight: 700; +} + +.resource-entry { display: grid; - gap: 3px; - min-height: 54px; - padding: 8px 10px; - border: 0; - border-bottom: 1px solid var(--border); - background: transparent; - color: #17211b; - cursor: pointer; - text-align: left; + grid-template-columns: minmax(170px, 1fr) minmax(180px, auto); + align-items: center; + gap: 8px; + padding: 5px 0; + border-bottom: 1px solid #ddd; } -.taxon-option:last-child { +.resource-entry:last-child { border-bottom: 0; } -.taxon-option[aria-pressed='true'] { - background: #edf7f1; - color: var(--accent-strong); +.resource-amount { + display: grid; + grid-template-columns: minmax(70px, 90px) minmax(80px, 120px); + gap: 8px; } -.taxon-option span { - font-weight: 800; -} - -.taxon-option small { - color: var(--muted); - font-size: 0.78rem; +.schedule-interval { + grid-template-columns: minmax(90px, 120px); } .quick-taxon { display: grid; gap: 12px; - padding-top: 14px; + padding-top: 10px; border-top: 1px solid var(--border); + background: #fafafa; } -.quick-taxon h3 { - margin-bottom: 0; -} - -.quick-taxon-fields { +.plant-detail-meta, +.plant-detail-grid { display: grid; - gap: 12px; } -.quick-taxon label { +.plant-detail-meta { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + border: 1px solid var(--border); + border-bottom: 0; +} + +.plant-detail-meta div { display: grid; + gap: 2px; + min-width: 0; + padding: 7px 8px; + border-right: 1px solid var(--border); +} + +.plant-detail-meta div:last-child { + border-right: 0; +} + +.plant-detail-meta span { + color: #555; + font-size: 0.72rem; + font-weight: 700; +} + +.plant-detail-meta strong { + color: #111; + font-size: 0.9rem; + line-height: 1.2; +} + +.plant-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-section { + min-width: 0; +} + +.detail-section-wide { + grid-column: 1 / -1; +} + +.detail-section-heading { + display: flex; + align-items: center; gap: 6px; - color: var(--muted); - font-size: 0.78rem; - font-weight: 800; -} - -.quick-taxon input { - width: 100%; - min-height: 42px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: white; - color: #17211b; - font: inherit; -} - -.quick-taxon input:focus { - border-color: var(--accent); - outline: 2px solid rgb(39 103 73 / 18%); -} - -.plant-form .toggle-field { - display: flex; - min-height: 42px; - align-items: center; - gap: 10px; - align-self: end; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: #fbfdfb; - color: #17211b; -} - -.plant-form .toggle-field input { - width: 18px; - min-height: 18px; - accent-color: var(--accent); -} - -.form-actions, -.row-actions { - display: flex; - align-items: center; - gap: 10px; -} - -.plant-list { - display: grid; - gap: 10px; -} - -.plant-row { - display: grid; - grid-template-columns: 40px 1fr auto; - align-items: center; - gap: 12px; - padding: 12px; -} - -.plant-row p { - margin-bottom: 0; - color: var(--muted); - font-size: 0.9rem; -} - -.plant-mark { - display: grid; - width: 40px; - height: 40px; - place-items: center; - border-radius: 8px; - background: #edf7f1; - color: var(--accent); -} - -.status-pill { - display: inline-flex; - align-items: center; min-height: 28px; - padding: 0 10px; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 800; + padding-bottom: 3px; + border-bottom: 1px solid #111; } -.plant-card h3 { - margin-top: 14px; +.detail-section-heading h3 { + margin: 0; } -dl { +.detail-row { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; gap: 10px; - margin: 14px 0 0; + padding: 7px 8px; } -dt { - color: var(--muted); - font-size: 0.75rem; - font-weight: 700; +.flag-list, +.flag-assignment-form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 6px; } -dd { - margin: 3px 0 0; - font-size: 0.9rem; +.flag-list { + margin-top: 5px; +} + +.flag-list.compact { + margin-top: 0; +} + +.flag-chip { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 0 7px; + border: 1px solid #999; + color: #111; + font-size: 0.74rem; font-weight: 700; + line-height: 1; +} + +.flag-assignment-form { + padding: 8px; + border-bottom: 1px solid var(--border); + background: #fafafa; +} + +.flag-assignment-form label { + min-width: 130px; +} + +.resolved-flags { + margin-top: 8px; + opacity: 0.72; } .bottom-nav { - position: fixed; - right: 12px; - bottom: 12px; - left: 12px; - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 4px; - margin: 0 auto; - max-width: 480px; - padding: 6px; - border: 1px solid var(--border); - border-radius: 8px; - background: rgb(255 255 255 / 94%); - box-shadow: 0 14px 40px rgb(25 46 31 / 18%); - backdrop-filter: blur(12px); + display: none; } -.bottom-nav button { - display: grid; - min-height: 48px; - place-items: center; - border: 0; - border-radius: 7px; - background: transparent; - color: var(--muted); - cursor: pointer; - font-size: 0.75rem; - font-weight: 800; - text-decoration: none; -} - -.bottom-nav button[aria-current='page'] { - background: #edf7f1; - color: var(--accent-strong); -} - -@media (min-width: 720px) { - .app-shell { - padding: 28px 28px 40px; +@media (max-width: 980px) { + .plant-card { + grid-template-columns: minmax(0, 1fr) max-content; } - .summary-panel { - grid-template-columns: 1fr auto; - align-items: center; + .plant-card > h3, + .plant-card .taxon, + .plant-card dl { + grid-column: 1; } - .plant-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + .plant-card-actions { + grid-column: 2; + grid-row: 1 / 4; } +} - .plant-form { - grid-template-columns: repeat(2, minmax(0, 1fr)); +@media (max-width: 820px) { + .catalog-header { + position: static; + grid-template-columns: 1fr; + gap: 8px; + padding: 10px 12px; } - .taxon-picker { - grid-row: span 3; + .catalog-contact, + .catalog-help { + display: none; } - .quick-taxon-fields { - grid-template-columns: repeat(3, minmax(0, 1fr)); + .catalog-layout { + grid-template-columns: 1fr; + max-width: 100vw; + border: 0; } - .bottom-nav { - top: 28px; - right: max(28px, calc((100vw - 1040px) / 2)); - bottom: auto; - left: auto; - width: 410px; + .catalog-sidebar { + position: static; + min-height: 0; + padding: 10px 12px; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .catalog-nav { + display: flex; + width: 100%; + max-width: 100%; + gap: 6px; + overflow-x: auto; + overscroll-behavior-x: contain; + padding-bottom: 2px; + } + + .catalog-nav button { + flex: 0 0 auto; + padding: 4px 2px; + font-size: 0.78rem; + white-space: nowrap; + } + + .catalog-main { + padding: 12px; + } + + .catalog-page-title { + display: block; + } + + .summary-panel, + .plant-row, + .task-row, + .detail-row, + .plant-detail-grid, + .plant-form, + .quick-taxon-fields, + .resource-entry, + .resource-amount, + .flag-assignment-form { + grid-template-columns: 1fr; + } + + .plant-detail-meta { + grid-template-columns: 1fr; + } + + .plant-detail-meta div { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .plant-detail-meta div:last-child { + border-bottom: 0; + } +} + +@media (max-width: 560px) { + .plant-card { + grid-template-columns: minmax(0, 1fr); + } + + .plant-card-actions { + grid-column: 1; + grid-row: auto; + justify-self: start; } } diff --git a/plant-manager/Contracts.cs b/plant-manager/Contracts.cs index 8f611df..e6001e5 100644 --- a/plant-manager/Contracts.cs +++ b/plant-manager/Contracts.cs @@ -6,15 +6,18 @@ namespace plant_manager string Nickname, int TaxonId, string? Location, - DateOnly? LastWateredOn, - int? WaterEveryDays); + IReadOnlyList? CareSchedules); public record UpdatePlantRequest( string Nickname, int TaxonId, string? Location, - DateOnly? LastWateredOn, - int? WaterEveryDays); + IReadOnlyList? CareSchedules); + + public record SavePlantCareScheduleRequest( + int CareActionId, + int? EveryDays, + bool IsEnabled); public record SavePlantTaxonRequest( string Name, @@ -35,11 +38,42 @@ namespace plant_manager string? Notes, bool IsEnabled); + public record SavePlantFlagDefinitionRequest( + string Name, + string? Category, + string? Color, + bool IsEnabled); + + public record AssignPlantFlagRequest( + int PlantFlagDefinitionId, + string? Severity, + DateOnly? StartedOn, + string? Notes); + + public record UpdatePlantFlagRequest( + string? Severity, + DateOnly? StartedOn, + DateOnly? ResolvedOn, + string? Notes); + public record CreateActionLogRequest( int PlantId, - string Action, + int CareActionId, string? Notes, - DateOnly? PerformedOn); + DateOnly? PerformedOn, + IReadOnlyList? Resources); + + public record UpdateActionLogRequest( + int PlantId, + int CareActionId, + string? Notes, + DateOnly PerformedOn, + IReadOnlyList? Resources); + + public record ActionLogResourceRequest( + int ActionResourceId, + decimal? Quantity, + string? Unit); public record PlantTaxonDto( int Id, @@ -88,16 +122,27 @@ namespace plant_manager int TaxonId, string Taxon, string Location, - DateOnly? LastWateredOn, - string LastWatered, string NextCare, - int WaterEveryDays, - string Status) + string Status, + IReadOnlyList Flags, + IReadOnlyList CareSchedules) { public static PlantDto FromPlant(Plant plant) { var today = DateOnly.FromDateTime(DateTime.UtcNow); - var nextCare = PlantCareFormatter.GetNextCareDate(plant); + var schedules = plant.CareSchedules + .OrderBy(schedule => schedule.CareAction.Name) + .Select(schedule => PlantCareScheduleDto.FromSchedule( + schedule, + GetLatestPerformedOn(plant, schedule.CareActionId), + today)) + .ToList(); + var nextCare = schedules + .Where(schedule => schedule.IsEnabled) + .Select(schedule => PlantCareFormatter.GetNextCareDate(schedule.LastPerformedOn, schedule.EveryDays)) + .Where(date => date is not null) + .OrderBy(date => date) + .FirstOrDefault(); return new PlantDto( plant.Id, @@ -105,11 +150,52 @@ namespace plant_manager plant.TaxonId, $"{plant.Taxon.Genus} {plant.Taxon.Species}", plant.Location, - plant.LastWateredOn, - PlantCareFormatter.FormatRelativeDate(plant.LastWateredOn, today, "Never"), PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"), - plant.WaterEveryDays, - PlantCareFormatter.GetStatus(nextCare, today)); + PlantCareFormatter.GetStatus(nextCare, today), + plant.Flags + .OrderBy(flag => flag.ResolvedOn is not null) + .ThenByDescending(flag => flag.StartedOn) + .ThenBy(flag => flag.Definition.Name) + .Select(PlantFlagDto.FromPlantFlag) + .ToList(), + schedules); + } + + private static DateOnly? GetLatestPerformedOn(Plant plant, int careActionId) => + plant.ActionLogs + .Where(log => log.CareActionId == careActionId) + .Select(log => (DateOnly?)log.PerformedOn) + .Max(); + } + + public record PlantCareScheduleDto( + int Id, + int CareActionId, + string Action, + int EveryDays, + DateOnly? LastPerformedOn, + string LastPerformed, + string NextCare, + string Status, + bool IsEnabled) + { + public static PlantCareScheduleDto FromSchedule( + PlantCareSchedule schedule, + DateOnly? lastPerformedOn, + DateOnly today) + { + var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays); + + return new PlantCareScheduleDto( + schedule.Id, + schedule.CareActionId, + schedule.CareAction.Name, + schedule.EveryDays, + lastPerformedOn, + PlantCareFormatter.FormatRelativeDate(lastPerformedOn, today, "Never"), + PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"), + PlantCareFormatter.GetStatus(nextCare, today), + schedule.IsEnabled); } } @@ -117,46 +203,119 @@ namespace plant_manager int Id, int PlantId, string PlantName, + int CareActionId, string Action, string Due, string Status) { - public static CareTaskDto FromPlant(Plant plant, DateOnly today) + public static CareTaskDto FromSchedule( + PlantCareSchedule schedule, + DateOnly? lastPerformedOn, + DateOnly today) { - var nextCare = PlantCareFormatter.GetNextCareDate(plant); + var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays); return new CareTaskDto( - plant.Id, - plant.Id, - plant.Nickname, - "Water", + schedule.Id, + schedule.PlantId, + schedule.Plant.Nickname, + schedule.CareActionId, + schedule.CareAction.Name, PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"), PlantCareFormatter.GetStatus(nextCare, today)); } } + public record PlantFlagDefinitionDto( + int Id, + string Name, + string Category, + string Color, + bool IsEnabled) + { + public static PlantFlagDefinitionDto FromDefinition(PlantFlagDefinition definition) => + new( + definition.Id, + definition.Name, + definition.Category, + definition.Color, + definition.IsEnabled); + } + + public record PlantFlagDto( + int Id, + int PlantFlagDefinitionId, + string Name, + string Category, + string Color, + string Severity, + DateOnly StartedOn, + DateOnly? ResolvedOn, + string? Notes) + { + public static PlantFlagDto FromPlantFlag(PlantFlag flag) => + new( + flag.Id, + flag.PlantFlagDefinitionId, + flag.Definition.Name, + flag.Definition.Category, + flag.Definition.Color, + flag.Severity, + flag.StartedOn, + flag.ResolvedOn, + flag.Notes); + } + public record ActionLogDto( int Id, int PlantId, string PlantName, + int CareActionId, string Action, string? Notes, - DateOnly PerformedOn) + DateOnly PerformedOn, + IReadOnlyList Resources) { public static ActionLogDto FromActionLog(ActionLog log) => - new(log.Id, log.PlantId, log.Plant.Nickname, log.Action, log.Notes, log.PerformedOn); + new( + log.Id, + log.PlantId, + log.Plant.Nickname, + log.CareActionId, + log.ActionNameSnapshot, + log.Notes, + log.PerformedOn, + log.Resources + .Select(resource => ActionLogResourceDto.FromActionLogResource(resource)) + .ToList()); + } + + public record ActionLogResourceDto( + int ActionResourceId, + string Name, + string? Category, + decimal? Quantity, + string? Unit) + { + public static ActionLogResourceDto FromActionLogResource(ActionLogResource resource) => + new( + resource.ActionResourceId, + resource.ActionResource.Name, + resource.ActionResource.Category, + resource.Quantity, + resource.Unit); } internal static class PlantCareFormatter { - public static DateOnly? GetNextCareDate(Plant plant) => - plant.LastWateredOn?.AddDays(plant.WaterEveryDays); + public static DateOnly? GetNextCareDate(DateOnly? lastPerformedOn, int everyDays) => + lastPerformedOn?.AddDays(everyDays); public static string GetStatus(DateOnly? date, DateOnly today) { if (date is null) { - return "due"; + return "unscheduled"; } if (date <= today) diff --git a/plant-manager/Data/ApplicationDbContext.cs b/plant-manager/Data/ApplicationDbContext.cs index 8665e6c..20974fa 100644 --- a/plant-manager/Data/ApplicationDbContext.cs +++ b/plant-manager/Data/ApplicationDbContext.cs @@ -10,6 +10,10 @@ namespace plant_manager.Data public DbSet CareActions { get; set; } public DbSet ActionResources { get; set; } public DbSet ActionLogs { get; set; } + public DbSet ActionLogResources { get; set; } + public DbSet PlantCareSchedules { get; set; } + public DbSet PlantFlagDefinitions { get; set; } + public DbSet PlantFlags { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -65,12 +69,79 @@ namespace plant_manager.Data modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); - entity.Property(e => e.Action).HasMaxLength(80).IsRequired(); + entity.Property(e => e.ActionNameSnapshot).HasMaxLength(80).IsRequired(); entity.Property(e => e.Notes).HasMaxLength(1000); entity.HasOne(e => e.Plant) .WithMany(e => e.ActionLogs) .HasForeignKey(e => e.PlantId) .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.CareAction) + .WithMany(e => e.ActionLogs) + .HasForeignKey(e => e.CareActionId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.ActionLogId, e.ActionResourceId }); + entity.Property(e => e.Quantity).HasPrecision(10, 2); + entity.Property(e => e.Unit).HasMaxLength(40); + entity.HasOne(e => e.ActionLog) + .WithMany(e => e.Resources) + .HasForeignKey(e => e.ActionLogId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.ActionResource) + .WithMany(e => e.ActionLogResources) + .HasForeignKey(e => e.ActionResourceId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.EveryDays).IsRequired(); + entity.Property(e => e.IsEnabled).IsRequired(); + entity.HasIndex(e => new { e.PlantId, e.CareActionId }).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) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.Name).HasMaxLength(120).IsRequired(); + entity.Property(e => e.Category).HasMaxLength(80).IsRequired(); + entity.Property(e => e.Color).HasMaxLength(20).IsRequired(); + entity.Property(e => e.IsEnabled).IsRequired(); + entity.HasIndex(e => e.Name).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.Severity).HasMaxLength(20).IsRequired(); + entity.Property(e => e.Notes).HasMaxLength(1000); + entity.HasIndex(e => new { e.PlantId, e.PlantFlagDefinitionId, e.ResolvedOn }); + entity.HasOne(e => e.Plant) + .WithMany(e => e.Flags) + .HasForeignKey(e => e.PlantId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.Definition) + .WithMany(e => e.PlantFlags) + .HasForeignKey(e => e.PlantFlagDefinitionId) + .OnDelete(DeleteBehavior.Restrict); }); } } diff --git a/plant-manager/Data/DatabaseSeeder.cs b/plant-manager/Data/DatabaseSeeder.cs index 64fd2f8..4660d45 100644 --- a/plant-manager/Data/DatabaseSeeder.cs +++ b/plant-manager/Data/DatabaseSeeder.cs @@ -26,6 +26,15 @@ namespace plant_manager.Data new() { Name = "Pruners", Category = "Equipment", Notes = "Cutting tool for pruning or cleanup." } ]; + private static readonly PlantFlagDefinition[] StarterFlagDefinitions = + [ + new() { Name = "Spider mites", Category = "Pest", Color = "#ffe4e1" }, + new() { Name = "Fungus gnats", Category = "Pest", Color = "#fff4cc" }, + new() { Name = "Quarantine", Category = "Workflow", Color = "#e8eef8" }, + new() { Name = "Needs repotting", Category = "Condition", Color = "#e9f5e7" }, + new() { Name = "Watch closely", Category = "Workflow", Color = "#eeeeee" } + ]; + private static readonly StarterTaxon[] StarterTaxa = [ new("Chinese Money Plant", "Pilea", "peperomioides"), @@ -50,47 +59,12 @@ namespace plant_manager.Data public static void Seed(ApplicationDbContext db) { - EnsureCareActionTable(db); - EnsureActionResourceTable(db); SeedCareActions(db); SeedActionResources(db); + SeedPlantFlags(db); SeedStarterTaxa(db); SeedStarterPlants(db); - } - - private static void EnsureCareActionTable(ApplicationDbContext db) - { - db.Database.ExecuteSqlRaw(""" - CREATE TABLE IF NOT EXISTS "CareActions" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_CareActions" PRIMARY KEY AUTOINCREMENT, - "Name" TEXT NOT NULL, - "Description" TEXT NULL, - "IsEnabled" INTEGER NOT NULL - ); - """); - - db.Database.ExecuteSqlRaw(""" - CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareActions_Name" - ON "CareActions" ("Name"); - """); - } - - private static void EnsureActionResourceTable(ApplicationDbContext db) - { - db.Database.ExecuteSqlRaw(""" - CREATE TABLE IF NOT EXISTS "ActionResources" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_ActionResources" PRIMARY KEY AUTOINCREMENT, - "Name" TEXT NOT NULL, - "Category" TEXT NULL, - "Notes" TEXT NULL, - "IsEnabled" INTEGER NOT NULL - ); - """); - - db.Database.ExecuteSqlRaw(""" - CREATE UNIQUE INDEX IF NOT EXISTS "IX_ActionResources_Name" - ON "ActionResources" ("Name"); - """); + SeedDefaultCareSchedules(db); } private static void SeedCareActions(ApplicationDbContext db) @@ -119,6 +93,33 @@ namespace plant_manager.Data db.SaveChanges(); } + private static void SeedDefaultCareSchedules(ApplicationDbContext db) + { + var waterAction = db.CareActions + .FirstOrDefault(action => action.Name == "Water"); + if (waterAction is null) + { + return; + } + + var plantsMissingWaterSchedule = db.Plants + .Include(plant => plant.CareSchedules) + .Where(plant => !plant.CareSchedules.Any(schedule => schedule.CareActionId == waterAction.Id)) + .ToList(); + + foreach (var plant in plantsMissingWaterSchedule) + { + plant.CareSchedules.Add(new PlantCareSchedule + { + CareActionId = waterAction.Id, + EveryDays = 7, + IsEnabled = true + }); + } + + db.SaveChanges(); + } + private static void SeedActionResources(ApplicationDbContext db) { var existingResourceNames = db.ActionResources @@ -146,6 +147,33 @@ namespace plant_manager.Data db.SaveChanges(); } + private static void SeedPlantFlags(ApplicationDbContext db) + { + var existingFlagNames = db.PlantFlagDefinitions + .Select(flag => flag.Name) + .ToList(); + + var missingFlags = StarterFlagDefinitions + .Where(starterFlag => !existingFlagNames.Any(existingName => + string.Equals(existingName, starterFlag.Name, StringComparison.OrdinalIgnoreCase))) + .Select(starterFlag => new PlantFlagDefinition + { + Name = starterFlag.Name, + Category = starterFlag.Category, + Color = starterFlag.Color, + IsEnabled = starterFlag.IsEnabled + }) + .ToList(); + + if (missingFlags.Count == 0) + { + return; + } + + db.PlantFlagDefinitions.AddRange(missingFlags); + db.SaveChanges(); + } + private static void SeedStarterTaxa(ApplicationDbContext db) { var existingTaxa = db.PlantTaxa.ToList(); @@ -184,31 +212,69 @@ namespace plant_manager.Data var today = DateOnly.FromDateTime(DateTime.UtcNow); - db.Plants.AddRange( - new Plant + var starterPlants = new[] + { + new { - Nickname = "Pothos", - Taxon = Taxon("Pothos"), - Location = "Living room", - LastWateredOn = today.AddDays(-7), - WaterEveryDays = 7 + Plant = new Plant + { + Nickname = "Pothos", + Taxon = Taxon("Pothos"), + Location = "Living room" + }, + InitialWateredOn = today.AddDays(-7), + WaterIntervalDays = 7 }, - new Plant + new { - Nickname = "Money Tree", - Taxon = Taxon("Money Tree"), - Location = "Bedroom", - LastWateredOn = today.AddDays(-13), - WaterEveryDays = 14 + Plant = new Plant + { + Nickname = "Money Tree", + Taxon = Taxon("Money Tree"), + Location = "Bedroom" + }, + InitialWateredOn = today.AddDays(-13), + WaterIntervalDays = 14 }, - new Plant + new { - Nickname = "Chinese Money Plant", - Taxon = Taxon("Chinese Money Plant"), - Location = "Kitchen", - LastWateredOn = today.AddDays(-2), - WaterEveryDays = 6 + Plant = new Plant + { + Nickname = "Chinese Money Plant", + Taxon = Taxon("Chinese Money Plant"), + Location = "Kitchen" + }, + InitialWateredOn = today.AddDays(-2), + WaterIntervalDays = 6 + } + }; + + db.Plants.AddRange(starterPlants.Select(starterPlant => starterPlant.Plant)); + db.SaveChanges(); + + var waterAction = db.CareActions.FirstOrDefault(action => action.Name == "Water"); + if (waterAction is null) + { + return; + } + + foreach (var starterPlant in starterPlants) + { + starterPlant.Plant.CareSchedules.Add(new PlantCareSchedule + { + CareActionId = waterAction.Id, + EveryDays = starterPlant.WaterIntervalDays, + IsEnabled = true }); + db.ActionLogs.Add(new ActionLog + { + PlantId = starterPlant.Plant.Id, + CareActionId = waterAction.Id, + ActionNameSnapshot = waterAction.Name, + Notes = "Starter watering history.", + PerformedOn = starterPlant.InitialWateredOn + }); + } db.SaveChanges(); } diff --git a/plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs b/plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs new file mode 100644 index 0000000..a0d4f65 --- /dev/null +++ b/plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs @@ -0,0 +1,415 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using plant_manager.Data; + +#nullable disable + +namespace plant_manager.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260518153034_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionNameSnapshot") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PerformedOn") + .HasColumnType("TEXT"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("PlantId"); + + b.ToTable("ActionLogs"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.Property("ActionLogId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("ActionLogId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("ActionLogResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ActionResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("TaxonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TaxonId"); + + b.ToTable("Plants"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("EveryDays") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("PlantId", "CareActionId") + .IsUnique(); + + b.ToTable("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PlantFlagDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.Property("ResolvedOn") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("StartedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlantFlagDefinitionId"); + + b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn"); + + b.ToTable("PlantFlags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantFlagDefinitions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Cultivar") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Genus") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Species") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Variety") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlantTaxa"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("ActionLogs") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("ActionLogs") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog") + .WithMany("Resources") + .HasForeignKey("ActionLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("ActionLogResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ActionLog"); + + b.Navigation("ActionResource"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon") + .WithMany() + .HasForeignKey("TaxonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("CareSchedules") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition") + .WithMany("PlantFlags") + .HasForeignKey("PlantFlagDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("Flags") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Navigation("Resources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Navigation("ActionLogResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("CareSchedules"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Navigation("PlantFlags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/plant-manager/Data/Migrations/20260518153034_InitialCreate.cs b/plant-manager/Data/Migrations/20260518153034_InitialCreate.cs new file mode 100644 index 0000000..3d51c1f --- /dev/null +++ b/plant-manager/Data/Migrations/20260518153034_InitialCreate.cs @@ -0,0 +1,304 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace plant_manager.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActionResources", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 80, nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActionResources", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CareActions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 400, nullable: true), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CareActions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlantFlagDefinitions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Color = table.Column(type: "TEXT", maxLength: 20, nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlantTaxa", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Genus = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Species = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Cultivar = table.Column(type: "TEXT", maxLength: 120, nullable: true), + Variety = table.Column(type: "TEXT", maxLength: 120, nullable: true), + Authority = table.Column(type: "TEXT", maxLength: 120, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlantTaxa", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Plants", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TaxonId = table.Column(type: "INTEGER", nullable: false), + Nickname = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Location = table.Column(type: "TEXT", maxLength: 120, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Plants", x => x.Id); + table.ForeignKey( + name: "FK_Plants_PlantTaxa_TaxonId", + column: x => x.TaxonId, + principalTable: "PlantTaxa", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ActionLogs", + 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), + ActionNameSnapshot = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + PerformedOn = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActionLogs", x => x.Id); + table.ForeignKey( + name: "FK_ActionLogs_CareActions_CareActionId", + column: x => x.CareActionId, + principalTable: "CareActions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ActionLogs_Plants_PlantId", + column: x => x.PlantId, + principalTable: "Plants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PlantCareSchedules", + 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), + EveryDays = table.Column(type: "INTEGER", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + 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_Plants_PlantId", + column: x => x.PlantId, + principalTable: "Plants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PlantFlags", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlantId = table.Column(type: "INTEGER", nullable: false), + PlantFlagDefinitionId = table.Column(type: "INTEGER", nullable: false), + Severity = table.Column(type: "TEXT", maxLength: 20, nullable: false), + StartedOn = table.Column(type: "TEXT", nullable: false), + ResolvedOn = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlantFlags", x => x.Id); + table.ForeignKey( + name: "FK_PlantFlags_PlantFlagDefinitions_PlantFlagDefinitionId", + column: x => x.PlantFlagDefinitionId, + principalTable: "PlantFlagDefinitions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PlantFlags_Plants_PlantId", + column: x => x.PlantId, + principalTable: "Plants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ActionLogResources", + columns: table => new + { + ActionLogId = table.Column(type: "INTEGER", nullable: false), + ActionResourceId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "TEXT", precision: 10, scale: 2, nullable: true), + Unit = table.Column(type: "TEXT", maxLength: 40, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ActionLogResources", x => new { x.ActionLogId, x.ActionResourceId }); + table.ForeignKey( + name: "FK_ActionLogResources_ActionLogs_ActionLogId", + column: x => x.ActionLogId, + principalTable: "ActionLogs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ActionLogResources_ActionResources_ActionResourceId", + column: x => x.ActionResourceId, + principalTable: "ActionResources", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ActionLogResources_ActionResourceId", + table: "ActionLogResources", + column: "ActionResourceId"); + + migrationBuilder.CreateIndex( + name: "IX_ActionLogs_CareActionId", + table: "ActionLogs", + column: "CareActionId"); + + migrationBuilder.CreateIndex( + name: "IX_ActionLogs_PlantId", + table: "ActionLogs", + column: "PlantId"); + + migrationBuilder.CreateIndex( + name: "IX_ActionResources_Name", + table: "ActionResources", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CareActions_Name", + table: "CareActions", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlantCareSchedules_CareActionId", + table: "PlantCareSchedules", + column: "CareActionId"); + + migrationBuilder.CreateIndex( + name: "IX_PlantCareSchedules_PlantId_CareActionId", + table: "PlantCareSchedules", + columns: new[] { "PlantId", "CareActionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlantFlagDefinitions_Name", + table: "PlantFlagDefinitions", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlantFlags_PlantFlagDefinitionId", + table: "PlantFlags", + column: "PlantFlagDefinitionId"); + + migrationBuilder.CreateIndex( + name: "IX_PlantFlags_PlantId_PlantFlagDefinitionId_ResolvedOn", + table: "PlantFlags", + columns: new[] { "PlantId", "PlantFlagDefinitionId", "ResolvedOn" }); + + migrationBuilder.CreateIndex( + name: "IX_Plants_TaxonId", + table: "Plants", + column: "TaxonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActionLogResources"); + + migrationBuilder.DropTable( + name: "PlantCareSchedules"); + + migrationBuilder.DropTable( + name: "PlantFlags"); + + migrationBuilder.DropTable( + name: "ActionLogs"); + + migrationBuilder.DropTable( + name: "ActionResources"); + + migrationBuilder.DropTable( + name: "PlantFlagDefinitions"); + + migrationBuilder.DropTable( + name: "CareActions"); + + migrationBuilder.DropTable( + name: "Plants"); + + migrationBuilder.DropTable( + name: "PlantTaxa"); + } + } +} diff --git a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..baef2bc --- /dev/null +++ b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,412 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using plant_manager.Data; + +#nullable disable + +namespace plant_manager.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionNameSnapshot") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PerformedOn") + .HasColumnType("TEXT"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("PlantId"); + + b.ToTable("ActionLogs"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.Property("ActionLogId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("ActionLogId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("ActionLogResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ActionResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("TaxonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TaxonId"); + + b.ToTable("Plants"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("EveryDays") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareActionId"); + + b.HasIndex("PlantId", "CareActionId") + .IsUnique(); + + b.ToTable("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PlantFlagDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("PlantId") + .HasColumnType("INTEGER"); + + b.Property("ResolvedOn") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("StartedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlantFlagDefinitionId"); + + b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn"); + + b.ToTable("PlantFlags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantFlagDefinitions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Cultivar") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Genus") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Species") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Variety") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlantTaxa"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("ActionLogs") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("ActionLogs") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog") + .WithMany("Resources") + .HasForeignKey("ActionLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("ActionLogResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ActionLog"); + + b.Navigation("ActionResource"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon") + .WithMany() + .HasForeignKey("TaxonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Taxon"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("CareSchedules") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b => + { + b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition") + .WithMany("PlantFlags") + .HasForeignKey("PlantFlagDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.Plant", "Plant") + .WithMany("Flags") + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => + { + b.Navigation("Resources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => + { + b.Navigation("ActionLogResources"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("CareSchedules"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => + { + b.Navigation("PlantFlags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/plant-manager/Data/Models/ActionLog.cs b/plant-manager/Data/Models/ActionLog.cs index 68363d4..a95f64f 100644 --- a/plant-manager/Data/Models/ActionLog.cs +++ b/plant-manager/Data/Models/ActionLog.cs @@ -4,10 +4,13 @@ namespace plant_manager.Data.Models { public int Id { get; set; } public int PlantId { get; set; } - public string Action { get; set; } = string.Empty; + public int CareActionId { get; set; } + public string ActionNameSnapshot { get; set; } = string.Empty; public string? Notes { get; set; } public DateOnly PerformedOn { get; set; } public Plant Plant { get; set; } = null!; + public CareAction CareAction { get; set; } = null!; + public List Resources { get; set; } = []; } } diff --git a/plant-manager/Data/Models/ActionLogResource.cs b/plant-manager/Data/Models/ActionLogResource.cs new file mode 100644 index 0000000..c05d054 --- /dev/null +++ b/plant-manager/Data/Models/ActionLogResource.cs @@ -0,0 +1,13 @@ +namespace plant_manager.Data.Models +{ + public class ActionLogResource + { + public int ActionLogId { get; set; } + public int ActionResourceId { get; set; } + public decimal? Quantity { get; set; } + public string? Unit { get; set; } + + public ActionLog ActionLog { get; set; } = null!; + public ActionResource ActionResource { get; set; } = null!; + } +} diff --git a/plant-manager/Data/Models/ActionResource.cs b/plant-manager/Data/Models/ActionResource.cs index 7f689e4..bbc0da8 100644 --- a/plant-manager/Data/Models/ActionResource.cs +++ b/plant-manager/Data/Models/ActionResource.cs @@ -8,5 +8,7 @@ namespace plant_manager.Data.Models public string? Category { get; set; } public string? Notes { get; set; } public bool IsEnabled { get; set; } = true; + + public List ActionLogResources { get; set; } = []; } } diff --git a/plant-manager/Data/Models/CareAction.cs b/plant-manager/Data/Models/CareAction.cs index e812544..3cab33f 100644 --- a/plant-manager/Data/Models/CareAction.cs +++ b/plant-manager/Data/Models/CareAction.cs @@ -7,5 +7,8 @@ namespace plant_manager.Data.Models public string Name { get; set; } = string.Empty; public string? Description { get; set; } public bool IsEnabled { get; set; } = true; + + public List ActionLogs { get; set; } = []; + public List PlantCareSchedules { get; set; } = []; } } diff --git a/plant-manager/Data/Models/Plant.cs b/plant-manager/Data/Models/Plant.cs index eece359..7c62692 100644 --- a/plant-manager/Data/Models/Plant.cs +++ b/plant-manager/Data/Models/Plant.cs @@ -6,10 +6,10 @@ namespace plant_manager.Data.Models public int TaxonId { get; set; } public string Nickname { get; set; } = string.Empty; public string Location { get; set; } = string.Empty; - public DateOnly? LastWateredOn { get; set; } - public int WaterEveryDays { get; set; } = 7; public PlantTaxon Taxon { get; set; } = null!; public List ActionLogs { get; set; } = []; + public List CareSchedules { get; set; } = []; + public List Flags { get; set; } = []; } } diff --git a/plant-manager/Data/Models/PlantCareSchedule.cs b/plant-manager/Data/Models/PlantCareSchedule.cs new file mode 100644 index 0000000..1de6a67 --- /dev/null +++ b/plant-manager/Data/Models/PlantCareSchedule.cs @@ -0,0 +1,14 @@ +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 EveryDays { get; set; } = 7; + public bool IsEnabled { get; set; } = true; + + public Plant Plant { get; set; } = null!; + public CareAction CareAction { get; set; } = null!; + } +} diff --git a/plant-manager/Data/Models/PlantFlag.cs b/plant-manager/Data/Models/PlantFlag.cs new file mode 100644 index 0000000..1b458c2 --- /dev/null +++ b/plant-manager/Data/Models/PlantFlag.cs @@ -0,0 +1,16 @@ +namespace plant_manager.Data.Models +{ + public class PlantFlag + { + public int Id { get; set; } + public int PlantId { get; set; } + public int PlantFlagDefinitionId { get; set; } + public string Severity { get; set; } = "medium"; + public DateOnly StartedOn { get; set; } + public DateOnly? ResolvedOn { get; set; } + public string? Notes { get; set; } + + public Plant Plant { get; set; } = null!; + public PlantFlagDefinition Definition { get; set; } = null!; + } +} diff --git a/plant-manager/Data/Models/PlantFlagDefinition.cs b/plant-manager/Data/Models/PlantFlagDefinition.cs new file mode 100644 index 0000000..cfd1b90 --- /dev/null +++ b/plant-manager/Data/Models/PlantFlagDefinition.cs @@ -0,0 +1,13 @@ +namespace plant_manager.Data.Models +{ + public class PlantFlagDefinition + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Color { get; set; } = "#f2f2f2"; + public bool IsEnabled { get; set; } = true; + + public List PlantFlags { get; set; } = []; + } +} diff --git a/plant-manager/Endpoints/ActionLogEndpoints.cs b/plant-manager/Endpoints/ActionLogEndpoints.cs index 73686e1..f14c91d 100644 --- a/plant-manager/Endpoints/ActionLogEndpoints.cs +++ b/plant-manager/Endpoints/ActionLogEndpoints.cs @@ -12,18 +12,13 @@ namespace plant_manager.Endpoints { var logs = await db.ActionLogs .Include(log => log.Plant) + .Include(log => log.Resources) + .ThenInclude(resource => resource.ActionResource) .OrderByDescending(log => log.PerformedOn) .ThenByDescending(log => log.Id) - .Select(log => new ActionLogDto( - log.Id, - log.PlantId, - log.Plant.Nickname, - log.Action, - log.Notes, - log.PerformedOn)) .ToListAsync(); - return Results.Ok(logs); + return Results.Ok(logs.Select(ActionLogDto.FromActionLog)); }); app.MapPost("/api/action-logs", async (CreateActionLogRequest request, ApplicationDbContext db) => @@ -34,25 +29,35 @@ namespace plant_manager.Endpoints return Results.BadRequest(new { error = "Plant was not found." }); } - var action = string.IsNullOrWhiteSpace(request.Action) - ? "Water" - : request.Action.Trim(); + var action = await db.CareActions.FindAsync(request.CareActionId); + if (action is null) + { + return Results.BadRequest(new { error = "Care action was not found." }); + } + + if (!action.IsEnabled) + { + return Results.BadRequest(new { error = "Disabled actions cannot be logged." }); + } var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow); + var (resources, resourceError) = await BuildLogResources(request.Resources, db); + if (resourceError is not null) + { + return Results.BadRequest(new { error = resourceError }); + } var log = new ActionLog { PlantId = plant.Id, - Action = action, + CareActionId = action.Id, + CareAction = action, + ActionNameSnapshot = action.Name, Notes = request.Notes?.Trim(), - PerformedOn = performedOn + PerformedOn = performedOn, + Resources = resources }; - if (string.Equals(action, "Water", StringComparison.OrdinalIgnoreCase)) - { - plant.LastWateredOn = performedOn; - } - db.ActionLogs.Add(log); await db.SaveChangesAsync(); @@ -60,6 +65,116 @@ namespace plant_manager.Endpoints return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log)); }); + + app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) => + { + var log = await db.ActionLogs + .Include(item => item.Plant) + .Include(item => item.Resources) + .FirstOrDefaultAsync(item => item.Id == id); + if (log is null) + { + return Results.NotFound(); + } + + var oldPlantId = log.PlantId; + var oldCareActionId = log.CareActionId; + + var plant = await db.Plants.FindAsync(request.PlantId); + if (plant is null) + { + return Results.BadRequest(new { error = "Plant was not found." }); + } + + var action = await db.CareActions.FindAsync(request.CareActionId); + if (action is null) + { + return Results.BadRequest(new { error = "Care action was not found." }); + } + + if (!action.IsEnabled) + { + return Results.BadRequest(new { error = "Disabled actions cannot be logged." }); + } + + var (resources, resourceError) = await BuildLogResources(request.Resources, db); + if (resourceError is not null) + { + return Results.BadRequest(new { error = resourceError }); + } + + db.ActionLogResources.RemoveRange(log.Resources); + + log.PlantId = plant.Id; + log.Plant = plant; + log.CareActionId = action.Id; + log.CareAction = action; + log.ActionNameSnapshot = action.Name; + log.Notes = request.Notes?.Trim(); + log.PerformedOn = request.PerformedOn; + log.Resources = resources; + + await db.SaveChangesAsync(); + + return Results.Ok(ActionLogDto.FromActionLog(log)); + }); + + app.MapDelete("/api/action-logs/{id:int}", async (int id, ApplicationDbContext db) => + { + var log = await db.ActionLogs.FindAsync(id); + if (log is null) + { + return Results.NotFound(); + } + + db.ActionLogs.Remove(log); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }); } + + private static async Task<(List Resources, string? Error)> BuildLogResources( + IReadOnlyList? requestResources, + ApplicationDbContext db) + { + var requestedResources = requestResources? + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()) + .ToList() ?? []; + + if (requestedResources.Any(resource => resource.Quantity < 0)) + { + return ([], "Resource quantities cannot be negative."); + } + + var resourceIds = requestedResources + .Select(resource => resource.ActionResourceId) + .ToList(); + var resourcesById = await db.ActionResources + .Where(resource => resourceIds.Contains(resource.Id)) + .ToDictionaryAsync(resource => resource.Id); + + if (resourcesById.Count != resourceIds.Count) + { + return ([], "One or more resources were not found."); + } + + if (resourcesById.Values.Any(resource => !resource.IsEnabled)) + { + return ([], "Disabled resources cannot be logged."); + } + + return (requestedResources + .Select(resource => new ActionLogResource + { + ActionResourceId = resource.ActionResourceId, + ActionResource = resourcesById[resource.ActionResourceId], + Quantity = resource.Quantity, + Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim() + }) + .ToList(), null); + } + } } diff --git a/plant-manager/Endpoints/ActionResourceEndpoints.cs b/plant-manager/Endpoints/ActionResourceEndpoints.cs index a3e9d5b..fc2cf1f 100644 --- a/plant-manager/Endpoints/ActionResourceEndpoints.cs +++ b/plant-manager/Endpoints/ActionResourceEndpoints.cs @@ -87,6 +87,14 @@ namespace plant_manager.Endpoints return Results.NotFound(); } + var hasLogs = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id); + if (hasLogs) + { + resource.IsEnabled = false; + await db.SaveChangesAsync(); + return Results.Conflict(new { error = "Resource has care history, so it was disabled instead of deleted." }); + } + db.ActionResources.Remove(resource); await db.SaveChangesAsync(); diff --git a/plant-manager/Endpoints/CareActionEndpoints.cs b/plant-manager/Endpoints/CareActionEndpoints.cs index 8514703..8df1ff9 100644 --- a/plant-manager/Endpoints/CareActionEndpoints.cs +++ b/plant-manager/Endpoints/CareActionEndpoints.cs @@ -84,7 +84,7 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var hasLogs = await db.ActionLogs.AnyAsync(log => log.Action.ToLower() == action.Name.ToLower()); + var hasLogs = await db.ActionLogs.AnyAsync(log => log.CareActionId == id); if (hasLogs) { action.IsEnabled = false; diff --git a/plant-manager/Endpoints/CareTaskEndpoints.cs b/plant-manager/Endpoints/CareTaskEndpoints.cs index d603b1f..cca6a43 100644 --- a/plant-manager/Endpoints/CareTaskEndpoints.cs +++ b/plant-manager/Endpoints/CareTaskEndpoints.cs @@ -7,21 +7,42 @@ namespace plant_manager.Endpoints { public static void MapCareTaskEndpoints(this WebApplication app) { - app.MapGet("/api/care-tasks/today", async (ApplicationDbContext db) => + async Task GetUpcomingCareTasks(ApplicationDbContext db) { var today = DateOnly.FromDateTime(DateTime.UtcNow); - var plants = await db.Plants - .Include(plant => plant.Taxon) - .OrderBy(plant => plant.Nickname) + var schedules = await db.PlantCareSchedules + .Include(schedule => schedule.Plant) + .Include(schedule => schedule.CareAction) + .Where(schedule => schedule.IsEnabled && schedule.CareAction.IsEnabled) + .OrderBy(schedule => schedule.Plant.Nickname) + .ThenBy(schedule => schedule.CareAction.Name) .ToListAsync(); + var latestLogs = await db.ActionLogs + .GroupBy(log => new { log.PlantId, log.CareActionId }) + .Select(group => new + { + group.Key.PlantId, + group.Key.CareActionId, + LastPerformedOn = group.Max(log => log.PerformedOn) + }) + .ToListAsync(); + var latestLogLookup = latestLogs.ToDictionary( + log => (log.PlantId, log.CareActionId), + log => (DateOnly?)log.LastPerformedOn); - var tasks = plants - .Select(plant => CareTaskDto.FromPlant(plant, today)) + var tasks = schedules + .Select(schedule => CareTaskDto.FromSchedule( + schedule, + latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActionId)), + today)) .Where(task => task.Status is "due" or "soon") .ToList(); return Results.Ok(tasks); - }); + } + + app.MapGet("/api/care-tasks/upcoming", GetUpcomingCareTasks); + app.MapGet("/api/care-tasks/today", GetUpcomingCareTasks); } } } diff --git a/plant-manager/Endpoints/PlantEndpoints.cs b/plant-manager/Endpoints/PlantEndpoints.cs index aaef3ee..40f01fc 100644 --- a/plant-manager/Endpoints/PlantEndpoints.cs +++ b/plant-manager/Endpoints/PlantEndpoints.cs @@ -12,17 +12,26 @@ namespace plant_manager.Endpoints { var plants = await db.Plants .Include(plant => plant.Taxon) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareAction) + .Include(plant => plant.ActionLogs) + .Include(plant => plant.Flags) + .ThenInclude(flag => flag.Definition) .OrderBy(plant => plant.Nickname) - .Select(plant => PlantDto.FromPlant(plant)) .ToListAsync(); - return Results.Ok(plants); + return Results.Ok(plants.Select(PlantDto.FromPlant)); }); app.MapGet("/api/plants/{id:int}", async (int id, ApplicationDbContext db) => { var plant = await db.Plants .Include(item => item.Taxon) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareAction) + .Include(plant => plant.ActionLogs) + .Include(plant => plant.Flags) + .ThenInclude(flag => flag.Definition) .FirstOrDefaultAsync(item => item.Id == id); return plant is null @@ -47,15 +56,20 @@ namespace plant_manager.Endpoints { Nickname = request.Nickname.Trim(), Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(), - TaxonId = request.TaxonId, - LastWateredOn = request.LastWateredOn, - WaterEveryDays = Math.Clamp(request.WaterEveryDays ?? 7, 1, 365) + TaxonId = request.TaxonId }; db.Plants.Add(plant); await db.SaveChangesAsync(); plant.Taxon = taxon; + var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db); + if (scheduleError is not null) + { + return Results.BadRequest(new { error = scheduleError }); + } + + await db.SaveChangesAsync(); return Results.Created($"/api/plants/{plant.Id}", PlantDto.FromPlant(plant)); }); @@ -69,6 +83,11 @@ namespace plant_manager.Endpoints var plant = await db.Plants .Include(item => item.Taxon) + .Include(item => item.CareSchedules) + .ThenInclude(schedule => schedule.CareAction) + .Include(item => item.ActionLogs) + .Include(item => item.Flags) + .ThenInclude(flag => flag.Definition) .FirstOrDefaultAsync(item => item.Id == id); if (plant is null) @@ -86,8 +105,11 @@ namespace plant_manager.Endpoints plant.Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(); plant.TaxonId = request.TaxonId; plant.Taxon = taxon; - plant.LastWateredOn = request.LastWateredOn; - plant.WaterEveryDays = Math.Clamp(request.WaterEveryDays ?? 7, 1, 365); + var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db); + if (scheduleError is not null) + { + return Results.BadRequest(new { error = scheduleError }); + } await db.SaveChangesAsync(); @@ -108,5 +130,74 @@ namespace plant_manager.Endpoints return Results.NoContent(); }); } + + private static async Task ApplyCareSchedules( + Plant plant, + IReadOnlyList? requestedSchedules, + ApplicationDbContext db) + { + var schedules = requestedSchedules?.ToList(); + if (schedules is null) + { + var waterAction = await db.CareActions + .FirstOrDefaultAsync(action => action.Name.ToLower() == "water"); + if (waterAction is null) + { + return null; + } + + schedules = + [ + new SavePlantCareScheduleRequest( + waterAction.Id, + 7, + true) + ]; + } + + var normalizedSchedules = schedules + .GroupBy(schedule => schedule.CareActionId) + .Select(group => group.First()) + .Where(schedule => schedule.CareActionId > 0) + .ToList(); + var actionIds = normalizedSchedules + .Select(schedule => schedule.CareActionId) + .ToList(); + var actionsById = await db.CareActions + .Where(action => actionIds.Contains(action.Id)) + .ToDictionaryAsync(action => action.Id); + + if (actionsById.Count != actionIds.Count) + { + return "One or more care actions were not found."; + } + + var requestedActionIds = actionIds.ToHashSet(); + var schedulesToRemove = plant.CareSchedules + .Where(schedule => !requestedActionIds.Contains(schedule.CareActionId)) + .ToList(); + db.PlantCareSchedules.RemoveRange(schedulesToRemove); + + foreach (var requestedSchedule in normalizedSchedules) + { + var schedule = plant.CareSchedules + .FirstOrDefault(item => item.CareActionId == requestedSchedule.CareActionId); + if (schedule is null) + { + schedule = new PlantCareSchedule + { + PlantId = plant.Id, + CareActionId = requestedSchedule.CareActionId, + CareAction = actionsById[requestedSchedule.CareActionId] + }; + plant.CareSchedules.Add(schedule); + } + + schedule.EveryDays = Math.Clamp(requestedSchedule.EveryDays ?? 7, 1, 365); + schedule.IsEnabled = requestedSchedule.IsEnabled; + } + + return null; + } } } diff --git a/plant-manager/Endpoints/PlantFlagEndpoints.cs b/plant-manager/Endpoints/PlantFlagEndpoints.cs new file mode 100644 index 0000000..0d94d31 --- /dev/null +++ b/plant-manager/Endpoints/PlantFlagEndpoints.cs @@ -0,0 +1,236 @@ +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; +using plant_manager.Data.Models; + +namespace plant_manager.Endpoints +{ + public static class PlantFlagEndpoints + { + private static readonly HashSet ValidSeverities = new(StringComparer.OrdinalIgnoreCase) + { + "low", + "medium", + "high" + }; + + public static void MapPlantFlagEndpoints(this WebApplication app) + { + app.MapGet("/api/plant-flags", async (ApplicationDbContext db) => + { + var definitions = await db.PlantFlagDefinitions + .OrderByDescending(definition => definition.IsEnabled) + .ThenBy(definition => definition.Category) + .ThenBy(definition => definition.Name) + .Select(definition => PlantFlagDefinitionDto.FromDefinition(definition)) + .ToListAsync(); + + return Results.Ok(definitions); + }); + + app.MapPost("/api/plant-flags", async (SavePlantFlagDefinitionRequest request, ApplicationDbContext db) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "Flag name is required." }); + } + + var name = request.Name.Trim(); + var exists = await db.PlantFlagDefinitions.AnyAsync(definition => + definition.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "A flag with this name already exists." }); + } + + var definition = new PlantFlagDefinition + { + Name = name, + Category = NormalizeCategory(request.Category), + Color = NormalizeColor(request.Color), + IsEnabled = request.IsEnabled + }; + + db.PlantFlagDefinitions.Add(definition); + await db.SaveChangesAsync(); + + return Results.Created($"/api/plant-flags/{definition.Id}", PlantFlagDefinitionDto.FromDefinition(definition)); + }); + + app.MapPut("/api/plant-flags/{id:int}", async (int id, SavePlantFlagDefinitionRequest request, ApplicationDbContext db) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "Flag name is required." }); + } + + var name = request.Name.Trim(); + var definition = await db.PlantFlagDefinitions.FindAsync(id); + if (definition is null) + { + return Results.NotFound(); + } + + var exists = await db.PlantFlagDefinitions.AnyAsync(item => + item.Id != id && item.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "A flag with this name already exists." }); + } + + definition.Name = name; + definition.Category = NormalizeCategory(request.Category); + definition.Color = NormalizeColor(request.Color); + definition.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(PlantFlagDefinitionDto.FromDefinition(definition)); + }); + + app.MapDelete("/api/plant-flags/{id:int}", async (int id, ApplicationDbContext db) => + { + var definition = await db.PlantFlagDefinitions.FindAsync(id); + if (definition is null) + { + return Results.NotFound(); + } + + var isUsed = await db.PlantFlags.AnyAsync(flag => flag.PlantFlagDefinitionId == id); + if (isUsed) + { + definition.IsEnabled = false; + await db.SaveChangesAsync(); + return Results.Conflict(new { error = "Flag is assigned to plants, so it was disabled instead of deleted." }); + } + + db.PlantFlagDefinitions.Remove(definition); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }); + + app.MapPost("/api/plants/{plantId:int}/flags", async ( + int plantId, + AssignPlantFlagRequest request, + ApplicationDbContext db) => + { + var plant = await db.Plants.FindAsync(plantId); + if (plant is null) + { + return Results.NotFound(); + } + + var definition = await db.PlantFlagDefinitions.FindAsync(request.PlantFlagDefinitionId); + if (definition is null || !definition.IsEnabled) + { + return Results.BadRequest(new { error = "Flag was not found or is disabled." }); + } + + var hasActiveFlag = await db.PlantFlags.AnyAsync(flag => + flag.PlantId == plantId + && flag.PlantFlagDefinitionId == request.PlantFlagDefinitionId + && flag.ResolvedOn == null); + if (hasActiveFlag) + { + return Results.Conflict(new { error = "This plant already has that active flag." }); + } + + var flag = new PlantFlag + { + PlantId = plantId, + PlantFlagDefinitionId = request.PlantFlagDefinitionId, + Definition = definition, + Severity = NormalizeSeverity(request.Severity), + StartedOn = request.StartedOn ?? DateOnly.FromDateTime(DateTime.UtcNow), + Notes = NormalizeNotes(request.Notes) + }; + + db.PlantFlags.Add(flag); + await db.SaveChangesAsync(); + + return Results.Created($"/api/plants/{plantId}/flags/{flag.Id}", PlantFlagDto.FromPlantFlag(flag)); + }); + + app.MapPut("/api/plants/{plantId:int}/flags/{flagId:int}", async ( + int plantId, + int flagId, + UpdatePlantFlagRequest request, + ApplicationDbContext db) => + { + var flag = await db.PlantFlags + .Include(item => item.Definition) + .FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId); + if (flag is null) + { + return Results.NotFound(); + } + + flag.Severity = NormalizeSeverity(request.Severity); + flag.StartedOn = request.StartedOn ?? flag.StartedOn; + flag.ResolvedOn = request.ResolvedOn; + flag.Notes = NormalizeNotes(request.Notes); + + await db.SaveChangesAsync(); + + return Results.Ok(PlantFlagDto.FromPlantFlag(flag)); + }); + + app.MapPost("/api/plants/{plantId:int}/flags/{flagId:int}/resolve", async ( + int plantId, + int flagId, + ApplicationDbContext db) => + { + var flag = await db.PlantFlags + .Include(item => item.Definition) + .FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId); + if (flag is null) + { + return Results.NotFound(); + } + + flag.ResolvedOn = DateOnly.FromDateTime(DateTime.UtcNow); + await db.SaveChangesAsync(); + + return Results.Ok(PlantFlagDto.FromPlantFlag(flag)); + }); + + app.MapDelete("/api/plants/{plantId:int}/flags/{flagId:int}", async ( + int plantId, + int flagId, + ApplicationDbContext db) => + { + var flag = await db.PlantFlags + .FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId); + if (flag is null) + { + return Results.NotFound(); + } + + db.PlantFlags.Remove(flag); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }); + } + + private static string NormalizeCategory(string? category) => + string.IsNullOrWhiteSpace(category) ? "General" : category.Trim(); + + private static string NormalizeColor(string? color) => + string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim(); + + private static string NormalizeSeverity(string? severity) + { + if (string.IsNullOrWhiteSpace(severity)) + { + return "medium"; + } + + var normalized = severity.Trim().ToLower(); + return ValidSeverities.Contains(normalized) ? normalized : "medium"; + } + + private static string? NormalizeNotes(string? notes) => + string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); + } +} diff --git a/plant-manager/Endpoints/RootEndpoints.cs b/plant-manager/Endpoints/RootEndpoints.cs index 0b2d080..75720fd 100644 --- a/plant-manager/Endpoints/RootEndpoints.cs +++ b/plant-manager/Endpoints/RootEndpoints.cs @@ -15,7 +15,7 @@ namespace plant_manager.Endpoints "/api/plant-taxa", "/api/care-actions", "/api/action-resources", - "/api/care-tasks/today", + "/api/care-tasks/upcoming", "/api/action-logs" } })); diff --git a/plant-manager/Program.cs b/plant-manager/Program.cs index bc59a1d..57657e3 100644 --- a/plant-manager/Program.cs +++ b/plant-manager/Program.cs @@ -35,7 +35,7 @@ app.UseCors(frontendPolicy); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); + db.Database.Migrate(); DatabaseSeeder.Seed(db); } @@ -46,5 +46,6 @@ app.MapCareActionEndpoints(); app.MapActionResourceEndpoints(); app.MapCareTaskEndpoints(); app.MapActionLogEndpoints(); +app.MapPlantFlagEndpoints(); app.Run(); diff --git a/plant-manager/plant-manager.csproj b/plant-manager/plant-manager.csproj index 9aa59bf..c937936 100644 --- a/plant-manager/plant-manager.csproj +++ b/plant-manager/plant-manager.csproj @@ -8,6 +8,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all