add plant flags, schedules, action logs

This commit is contained in:
2026-05-18 15:49:06 +00:00
parent bf39b0b959
commit 94d91b08e6
39 changed files with 4311 additions and 800 deletions
+2 -1
View File
@@ -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",
+16 -3
View File
@@ -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"
}
]
}
```
+14 -4
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+532 -166
View File
@@ -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<PlantTaxon[]>([]);
const [careActions, setCareActions] = useState<CareAction[]>([]);
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
const [actionLogs, setActionLogs] = useState<ActionLog[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -64,12 +80,23 @@ export function App() {
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
const [editingActionId, setEditingActionId] = useState<number | null>(null);
const [editingResourceId, setEditingResourceId] = useState<number | null>(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
const [editingActionLogId, setEditingActionLogId] = useState<number | null>(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<number | null>(null);
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm);
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
const [careLogForm, setCareLogForm] = useState<CareLogFormState>(emptyCareLogForm);
const [plantTaxonSearch, setPlantTaxonSearch] = useState('');
const [plantSearch, setPlantSearch] = useState('');
const [quickTaxonForm, setQuickTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [isCreatingPlantTaxon, setIsCreatingPlantTaxon] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="app-shell">
<header className="top-bar">
<div>
<p className="eyebrow">{getViewEyebrow(view)}</p>
<h1>{getViewTitle(view)}</h1>
<header className="catalog-header">
<div className="catalog-brand">
<span className="catalog-logo">Plant-Man</span>
<span className="catalog-subtitle">Plant Care Supply</span>
</div>
<button className="icon-button" type="button" aria-label="Search plants">
<label className="search-field">
<Search size={20} />
</button>
<span className="sr-only">Search plants</span>
<input
value={plantSearch}
type="search"
placeholder="Search plants"
onChange={(event) => setPlantSearch(event.target.value)}
/>
</label>
<div className="catalog-contact">
<strong>{plants.length}</strong>
<span>plants tracked</span>
</div>
</header>
<main className="content">
{view === 'plants' ? (
<PlantsView
activePlantName={activePlant?.nickname}
actionLogs={actionLogs}
careActions={careActions}
careLogForm={careLogForm}
error={error}
form={form}
isLoading={isLoading}
isSaving={isSaving}
plantTaxa={plantTaxa}
plants={plants}
onCancel={cancelEditing}
onDelete={(plant) => 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' ? (
<TaxaView
activeTaxonName={activeTaxon?.name}
error={error}
form={taxonForm}
isLoading={isLoading}
isSaving={isSaving}
taxa={plantTaxa}
onCancel={cancelEditingTaxon}
onDelete={(taxon) => void removeTaxon(taxon)}
onEdit={startEditingTaxon}
onFieldChange={updateTaxonForm}
onNew={startAddingTaxon}
onSave={() => void saveTaxon()}
/>
) : view === 'actions' ? (
<ActionsView
activeActionName={activeAction?.name}
actions={careActions}
error={error}
form={actionForm}
isLoading={isLoading}
isSaving={isSaving}
onCancel={cancelEditingAction}
onDelete={(action) => void removeAction(action)}
onEdit={startEditingAction}
onFieldChange={updateActionForm}
onNew={startAddingAction}
onSave={() => void saveAction()}
/>
) : view === 'resources' ? (
<ResourcesView
activeResourceName={activeResource?.name}
error={error}
form={resourceForm}
isLoading={isLoading}
isSaving={isSaving}
onCancel={cancelEditingResource}
onDelete={(resource) => void removeResource(resource)}
onEdit={startEditingResource}
onFieldChange={updateResourceForm}
onNew={startAddingResource}
onSave={() => void saveResource()}
resources={actionResources}
/>
) : (
<HomeView
careTasks={careTasks}
dueCount={dueCount}
error={error}
isLoading={isLoading}
plants={plants}
onCompleteTask={(task) => void completeTask(task)}
onNewPlant={startAddingPlant}
/>
)}
</main>
<div className="catalog-layout">
<aside className="catalog-sidebar">
<h2>Choose a Category</h2>
<nav className="catalog-nav" aria-label="Primary navigation">
<button
type="button"
aria-current={view === 'home' ? 'page' : undefined}
onClick={() => setView('home')}
>
Care Queue
</button>
<button
type="button"
aria-current={view === 'plants' ? 'page' : undefined}
onClick={() => setView('plants')}
>
Plants
</button>
<button
type="button"
aria-current={view === 'taxa' ? 'page' : undefined}
onClick={() => setView('taxa')}
>
Plant Taxa
</button>
<button
type="button"
aria-current={view === 'actions' ? 'page' : undefined}
onClick={() => setView('actions')}
>
Care Actions
</button>
<button
type="button"
aria-current={view === 'resources' ? 'page' : undefined}
onClick={() => setView('resources')}
>
Resources
</button>
<button
type="button"
aria-current={view === 'flags' ? 'page' : undefined}
onClick={() => setView('flags')}
>
Plant Flags
</button>
</nav>
<nav className="bottom-nav" aria-label="Primary navigation">
<button
type="button"
aria-current={view === 'home' ? 'page' : undefined}
onClick={() => setView('home')}
>
<Home size={19} />
Home
</button>
<button
type="button"
aria-current={view === 'plants' ? 'page' : undefined}
onClick={() => setView('plants')}
>
<Sprout size={19} />
Plants
</button>
<button
type="button"
aria-current={view === 'taxa' ? 'page' : undefined}
onClick={() => setView('taxa')}
>
<BookOpen size={19} />
Taxa
</button>
<button
type="button"
aria-current={view === 'actions' ? 'page' : undefined}
onClick={() => setView('actions')}
>
<ListChecks size={19} />
Actions
</button>
<button
type="button"
aria-current={view === 'resources' ? 'page' : undefined}
onClick={() => setView('resources')}
>
<Package size={19} />
Resources
</button>
</nav>
<section className="catalog-help" aria-label="Catalog status">
<h3>Catalog Status</h3>
<p>{dueCount} due</p>
<p>{careActions.filter((action) => action.isEnabled).length} active actions</p>
<p>{actionResources.filter((resource) => resource.isEnabled).length} active resources</p>
<p>{plantFlagDefinitions.filter((flag) => flag.isEnabled).length} active flags</p>
</section>
</aside>
<div className="catalog-main">
<div className="catalog-page-title">
<p className="eyebrow">{getViewEyebrow(view)}</p>
<h1>{getViewTitle(view)}</h1>
</div>
<main className="content">
{view === 'plants' ? (
<PlantsView
activePlantName={activePlant?.nickname}
actionResources={actionResources}
careActions={careActions}
careTasks={filteredCareTasks}
careLogForm={careLogForm}
editingActionLogId={editingActionLogId}
error={error}
form={form}
isCareLogEditorOpen={isCareLogEditorOpen}
isLoading={isLoading}
isPlantEditorOpen={isPlantEditorOpen}
isSaving={isSaving}
plantFlagDefinitions={plantFlagDefinitions}
plantFlagForm={plantFlagForm}
plantTaxa={plantTaxa}
plants={plants}
selectedPlant={selectedPlant}
visiblePlants={filteredPlants}
onCancel={cancelEditing}
onCancelCareLog={cancelCareLog}
onClosePlantDetail={closePlantDetail}
onDelete={(plant) => 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' ? (
<TaxaView
activeTaxonName={activeTaxon?.name}
error={error}
form={taxonForm}
isEditorOpen={isTaxonEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
taxa={plantTaxa}
onCancel={cancelEditingTaxon}
onDelete={(taxon) => void removeTaxon(taxon)}
onEdit={startEditingTaxon}
onFieldChange={updateTaxonForm}
onNew={startAddingTaxon}
onSave={() => void saveTaxon()}
/>
) : view === 'actions' ? (
<ActionsView
activeActionName={activeAction?.name}
actions={careActions}
error={error}
form={actionForm}
isEditorOpen={isActionEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
onCancel={cancelEditingAction}
onDelete={(action) => void removeAction(action)}
onEdit={startEditingAction}
onFieldChange={updateActionForm}
onNew={startAddingAction}
onSave={() => void saveAction()}
/>
) : view === 'resources' ? (
<ResourcesView
activeResourceName={activeResource?.name}
error={error}
form={resourceForm}
isEditorOpen={isResourceEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
onCancel={cancelEditingResource}
onDelete={(resource) => void removeResource(resource)}
onEdit={startEditingResource}
onFieldChange={updateResourceForm}
onNew={startAddingResource}
onSave={() => void saveResource()}
resources={actionResources}
/>
) : view === 'flags' ? (
<FlagsView
activeFlagName={activeFlagDefinition?.name}
error={error}
flags={plantFlagDefinitions}
form={flagDefinitionForm}
isEditorOpen={isFlagDefinitionEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
onCancel={cancelEditingFlagDefinition}
onDelete={(flag) => void removeFlagDefinition(flag)}
onEdit={startEditingFlagDefinition}
onFieldChange={updateFlagDefinitionForm}
onNew={startAddingFlagDefinition}
onSave={() => void saveFlagDefinition()}
/>
) : (
<HomeView
careTasks={filteredCareTasks}
dueCount={plantSearch.trim() ? visibleDueCount : dueCount}
error={error}
isLoading={isLoading}
isPlantSearchActive={Boolean(plantSearch.trim())}
plants={filteredPlants}
totalPlantCount={plants.length}
onCompleteTask={completeTask}
onNewPlant={startAddingPlant}
onOpenPlant={openPlantDetail}
/>
)}
</main>
</div>
</div>
</div>
);
}
@@ -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<number>, 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);
}
+64 -1
View File
@@ -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<PlantFlagDefinition[]>('/api/plant-flags');
}
export async function createPlantFlag(payload: PlantFlagDefinitionPayload) {
return request<PlantFlagDefinition>('/api/plant-flags', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updatePlantFlag(id: number, payload: PlantFlagDefinitionPayload) {
return request<PlantFlagDefinition>(`/api/plant-flags/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deletePlantFlag(id: number) {
return request<void>(`/api/plant-flags/${id}`, {
method: 'DELETE',
});
}
export async function assignPlantFlag(plantId: number, payload: AssignPlantFlagPayload) {
return request<PlantFlag>(`/api/plants/${plantId}/flags`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function resolvePlantFlag(plantId: number, flagId: number) {
return request<PlantFlag>(`/api/plants/${plantId}/flags/${flagId}/resolve`, {
method: 'POST',
});
}
export async function removePlantFlagAssignment(plantId: number, flagId: number) {
return request<void>(`/api/plants/${plantId}/flags/${flagId}`, {
method: 'DELETE',
});
}
export async function getCareTasks() {
return request<CareTask[]>('/api/care-tasks/today');
return request<CareTask[]>('/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<ActionLog>(`/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<void>(`/api/action-logs/${id}`, {
method: 'DELETE',
});
}
@@ -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({
</button>
</section>
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="action-editor-heading">
<div className="section-heading">
<div>
@@ -95,6 +98,7 @@ export function ActionsView({
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="actions-list-heading">
<div className="section-heading">
@@ -108,9 +112,6 @@ export function ActionsView({
{actions.map((action) => (
<article className="plant-row" key={action.id}>
<div className="plant-mark">
<ListChecks size={20} />
</div>
<div>
<h3>{action.name}</h3>
<p>
@@ -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 (
<>
<section className="summary-panel" aria-labelledby="flags-summary-heading">
<div>
<p className="eyebrow">Plant flags</p>
<h2 id="flags-summary-heading">
{isLoading ? 'Loading flags' : `${enabledCount} flags enabled`}
</h2>
<p>{error ?? 'Configure reusable pest, condition, and workflow flags for plants.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New flag
</button>
</section>
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="flag-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activeFlagName ? 'Editing' : 'New flag'}</p>
<h2 id="flag-editor-heading">{activeFlagName ?? 'Flag details'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="plant-form">
<label>
Name
<input
value={form.name}
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<label>
Category
<select
value={form.category}
onChange={(event) => onFieldChange('category', event.target.value)}
>
<option value="Pest">Pest</option>
<option value="Disease">Disease</option>
<option value="Condition">Condition</option>
<option value="Workflow">Workflow</option>
<option value="General">General</option>
</select>
</label>
<label>
Color
<input
type="color"
value={form.color}
onChange={(event) => onFieldChange('color', event.target.value)}
/>
</label>
<label className="toggle-field">
<input
checked={form.isEnabled}
type="checkbox"
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
/>
Enabled
</label>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save flag'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="flags-list-heading">
<div className="section-heading">
<h2 id="flags-list-heading">All Flags</h2>
</div>
<div className="plant-list">
{!isLoading && flags.length === 0 ? (
<p className="empty-state">No flags yet.</p>
) : null}
{flags.map((flag) => (
<article className="plant-row" key={flag.id}>
<div>
<h3>{flag.name}</h3>
<p>
{flag.category}
{' - '}
{flag.isEnabled ? 'Enabled' : 'Disabled'}
</p>
</div>
<div className="row-actions">
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
<button className="icon-button compact" type="button" aria-label={`Edit ${flag.name}`} onClick={() => onEdit(flag)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${flag.name}`} onClick={() => onDelete(flag)}>
<Trash2 size={17} />
</button>
</div>
</article>
))}
</div>
</section>
</>
);
}
+16 -4
View File
@@ -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({
<div className="task-list">
{!isLoading && careTasks.length === 0 ? (
<p className="empty-state">No care tasks yet.</p>
<p className="empty-state">
{isPlantSearchActive ? 'No care tasks match the search.' : 'No care tasks yet.'}
</p>
) : null}
{careTasks.map((task) => (
@@ -61,7 +69,7 @@ export function HomeView({
onClick={() => onCompleteTask(task)}
>
<CalendarCheck size={16} />
Done
Log
</button>
</article>
))}
@@ -77,12 +85,16 @@ export function HomeView({
</div>
<div className="plant-grid">
{!isLoading && plants.length === 0 ? (
{!isLoading && totalPlantCount === 0 ? (
<p className="empty-state">No plants yet.</p>
) : null}
{!isLoading && totalPlantCount > 0 && plants.length === 0 ? (
<p className="empty-state">No plants match the search.</p>
) : null}
{plants.map((plant) => (
<PlantCard plant={plant} key={plant.id} />
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
))}
</div>
</section>
+39 -13
View File
@@ -1,35 +1,61 @@
import { Leaf } from 'lucide-react';
import { Eye } from 'lucide-react';
import type { CareStatus, Plant } from '../domain';
const statusLabel: Record<CareStatus, string> = {
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 (
<article className="plant-card">
<div className="plant-card-top">
<div className="plant-mark">
<Leaf size={20} />
</div>
<span className={`status-pill ${plant.status}`}>
{statusLabel[plant.status]}
</span>
</div>
<h3>{plant.nickname}</h3>
<p className="taxon">{plant.taxon}</p>
<div className="taxon">
<p>{plant.taxon}</p>
{plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? (
<div className="flag-list">
{plant.flags
.filter((flag) => flag.resolvedOn === null)
.map((flag) => (
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
))}
</div>
) : null}
</div>
<dl>
<div>
<dt>Location</dt>
<dd>{plant.location}</dd>
</div>
<div>
<dt>Last watered</dt>
<dd>{plant.lastWatered}</dd>
<dt>Next care</dt>
<dd>{plant.nextCare}</dd>
</div>
</dl>
<div className="plant-card-actions">
<span className={`status-pill ${plant.status}`}>
{statusLabel[plant.status]}
</span>
{onOpen ? (
<button
className="icon-button compact"
type="button"
aria-label={`View ${plant.nickname}`}
onClick={() => onOpen(plant)}
>
<Eye size={17} />
</button>
) : null}
</div>
</article>
);
}
+504 -113
View File
@@ -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({
</h2>
<p>{error ?? 'Add, update, or remove plants from your collection.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New plant
</button>
<div className="row-actions">
<button className="small-action" type="button" onClick={() => onStartLogCare()}>
<ClipboardCheck size={16} />
Log care
</button>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New plant
</button>
</div>
</section>
{selectedPlant ? (
<section className="editor-panel plant-detail-panel" aria-labelledby="plant-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Plant focus</p>
<h2 id="plant-detail-heading">{selectedPlant.nickname}</h2>
</div>
<div className="row-actions">
<button
className="icon-button compact"
type="button"
aria-label={`Log care for ${selectedPlant.nickname}`}
onClick={() => onStartLogCare(selectedPlant)}
>
<ClipboardCheck size={17} />
</button>
<button
className="icon-button compact"
type="button"
aria-label={`Edit ${selectedPlant.nickname}`}
onClick={() => onEdit(selectedPlant)}
>
<Edit3 size={17} />
</button>
<button
className="icon-button compact"
type="button"
aria-label="Close plant detail"
onClick={onClosePlantDetail}
>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Taxon</span>
<strong>{selectedPlantTaxon ? formatTaxon(selectedPlantTaxon) : selectedPlant.taxon}</strong>
</div>
<div>
<span>Location</span>
<strong>{selectedPlant.location || 'No location'}</strong>
</div>
<div>
<span>Next care</span>
<strong>{selectedPlant.nextCare}</strong>
</div>
<div>
<span>Active flags</span>
{selectedPlantActiveFlags.length === 0 ? (
<strong>None</strong>
) : (
<div className="flag-list compact">
{selectedPlantActiveFlags.map((flag) => (
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
))}
</div>
)}
</div>
</div>
<div className="plant-detail-grid">
<section className="detail-section" aria-labelledby="plant-detail-schedules">
<div className="detail-section-heading">
<CalendarClock size={17} />
<h3 id="plant-detail-schedules">Schedules</h3>
</div>
{selectedPlantSchedules.length === 0 ? (
<p className="empty-state">No schedules enabled.</p>
) : (
<div className="detail-list">
{selectedPlantSchedules.map((schedule) => (
<div className="detail-row" key={schedule.id}>
<div>
<h4>{schedule.action}</h4>
<p>
Every {schedule.everyDays} days - Last {schedule.lastPerformed}
</p>
</div>
<span className={`status-pill ${schedule.status}`}>
{statusLabel[schedule.status]}
</span>
</div>
))}
</div>
)}
</section>
<section className="detail-section" aria-labelledby="plant-detail-tasks">
<div className="detail-section-heading">
<ClipboardCheck size={17} />
<h3 id="plant-detail-tasks">Next Tasks</h3>
</div>
{selectedPlantTasks.length === 0 ? (
<p className="empty-state">No upcoming tasks.</p>
) : (
<div className="detail-list">
{selectedPlantTasks.map((task) => (
<div className="detail-row" key={task.id}>
<div>
<h4>{task.action}</h4>
<p>{task.due}</p>
</div>
<div className="row-actions">
<span className={`status-pill ${task.status}`}>
{statusLabel[task.status]}
</span>
<button
className="small-action"
type="button"
onClick={() => onCompleteTask(task)}
>
<ClipboardCheck size={16} />
Log
</button>
</div>
</div>
))}
</div>
)}
</section>
<section className="detail-section detail-section-wide" aria-labelledby="plant-detail-flags">
<div className="detail-section-heading">
<ClipboardCheck size={17} />
<h3 id="plant-detail-flags">Plant Flags</h3>
</div>
<div className="flag-assignment-form">
<label>
Flag
<select
value={plantFlagForm.plantFlagDefinitionId}
onChange={(event) => onPlantFlagFieldChange('plantFlagDefinitionId', event.target.value)}
>
<option value="">Select a flag</option>
{enabledFlagDefinitions.map((flag) => (
<option key={flag.id} value={flag.id}>
{flag.name}
</option>
))}
</select>
</label>
<label>
Severity
<select
value={plantFlagForm.severity}
onChange={(event) => onPlantFlagFieldChange('severity', event.target.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<label>
Started
<input
type="date"
value={plantFlagForm.startedOn}
onChange={(event) => onPlantFlagFieldChange('startedOn', event.target.value)}
/>
</label>
<label>
Notes
<input
value={plantFlagForm.notes}
onChange={(event) => onPlantFlagFieldChange('notes', event.target.value)}
/>
</label>
<button
className="small-action"
type="button"
disabled={isSaving || enabledFlagDefinitions.length === 0}
onClick={onAssignFlag}
>
<Plus size={16} />
Add flag
</button>
</div>
{selectedPlantActiveFlags.length === 0 ? (
<p className="empty-state">No active flags.</p>
) : (
<div className="detail-list">
{selectedPlantActiveFlags.map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
</h4>
<p>
{flag.category} - {flag.severity} - Started {formatDate(flag.startedOn)}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
<div className="row-actions">
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
Resolve
</button>
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
<Trash2 size={17} />
</button>
</div>
</div>
))}
</div>
)}
{selectedPlantResolvedFlags.length > 0 ? (
<div className="detail-list resolved-flags">
{selectedPlantResolvedFlags.slice(0, 4).map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>{flag.name}</h4>
<p>
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
</div>
))}
</div>
) : null}
</section>
</div>
</section>
) : null}
{isCareLogEditorOpen ? (
<section className="editor-panel" aria-labelledby="care-log-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Manual entry</p>
<h2 id="care-log-heading">Log care</h2>
<h2 id="care-log-heading">{editingActionLogId === null ? 'Log care' : 'Edit care log'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Start care log" onClick={() => onStartLogCare()}>
<ClipboardCheck size={18} />
<button className="icon-button compact" type="button" aria-label="Clear care log" onClick={onCancelCareLog}>
<X size={18} />
</button>
</div>
@@ -121,14 +416,15 @@ export function PlantsView({
<label>
Action
<select
value={careLogForm.action}
onChange={(event) => onLogCareFieldChange('action', event.target.value)}
value={careLogForm.careActionId}
onChange={(event) => onLogCareFieldChange('careActionId', event.target.value)}
>
<option value="">Select an action</option>
{enabledCareActions.length === 0 ? (
<option value="">No enabled actions</option>
) : null}
{enabledCareActions.map((action) => (
<option key={action.id} value={action.name}>
<option key={action.id} value={action.id}>
{action.name}
</option>
))}
@@ -149,43 +445,87 @@ export function PlantsView({
onChange={(event) => onLogCareFieldChange('notes', event.target.value)}
/>
</label>
<fieldset className="resource-picker">
<legend>Resources</legend>
{enabledActionResources.length === 0 ? (
<p className="empty-state">No enabled resources.</p>
) : null}
{enabledActionResources.map((resource) => {
const resourceId = String(resource.id);
const selectedResource = careLogForm.resources.find(
(item) => item.actionResourceId === resourceId,
);
return (
<div className="resource-entry" key={resource.id}>
<label className="check-option">
<input
checked={Boolean(selectedResource)}
type="checkbox"
onChange={(event) => {
const nextResources = event.target.checked
? [
...careLogForm.resources,
{ actionResourceId: resourceId, quantity: '', unit: '' },
]
: careLogForm.resources.filter((item) => item.actionResourceId !== resourceId);
onLogCareFieldChange('resources', nextResources);
}}
/>
<span>{resource.name}</span>
</label>
{selectedResource ? (
<div className="resource-amount">
<label>
Qty
<input
min="0"
step="0.01"
type="number"
value={selectedResource.quantity}
onChange={(event) => onLogCareFieldChange(
'resources',
careLogForm.resources.map((item) => item.actionResourceId === resourceId
? { ...item, quantity: event.target.value }
: item),
)}
/>
</label>
<label>
Unit
<input
value={selectedResource.unit}
onChange={(event) => onLogCareFieldChange(
'resources',
careLogForm.resources.map((item) => item.actionResourceId === resourceId
? { ...item, unit: event.target.value }
: item),
)}
/>
</label>
</div>
) : null}
</div>
);
})}
</fieldset>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || enabledCareActions.length === 0} onClick={onLogCare}>
<ClipboardCheck size={18} />
{isSaving ? 'Logging' : 'Log care'}
{isSaving ? 'Saving' : editingActionLogId === null ? 'Log care' : 'Update log'}
</button>
{editingActionLogId === null ? null : (
<button className="text-button" type="button" onClick={onCancelCareLog}>
Cancel
</button>
)}
</div>
</section>
) : null}
<section className="section" aria-labelledby="care-history-heading">
<div className="section-heading">
<h2 id="care-history-heading">Care History</h2>
</div>
<div className="plant-list">
{!isLoading && actionLogs.length === 0 ? (
<p className="empty-state">No care logged yet.</p>
) : null}
{actionLogs.slice(0, 12).map((log) => (
<article className="plant-row" key={log.id}>
<div className="plant-mark">
<ClipboardCheck size={20} />
</div>
<div>
<h3>{log.action}</h3>
<p>
{log.plantName} - {formatLogDate(log.performedOn)}
{log.notes ? ` - ${log.notes}` : ''}
</p>
</div>
</article>
))}
</div>
</section>
{isPlantEditorOpen ? (
<section className="editor-panel" aria-labelledby="plant-editor-heading">
<div className="section-heading">
<div>
@@ -208,34 +548,26 @@ export function PlantsView({
<div className="taxon-picker">
<label>
Taxon
<input
value={taxonSearch}
onChange={(event) => onTaxonSearchChange(event.target.value)}
/>
<select
value={form.taxonId}
onChange={(event) => {
const taxon = plantTaxa.find((item) => String(item.id) === event.target.value);
if (taxon) {
onSelectTaxon(taxon);
return;
}
onFieldChange('taxonId', '');
}}
>
<option value="">Select a taxon</option>
{plantTaxa.map((taxon) => (
<option key={taxon.id} value={taxon.id}>
{formatTaxon(taxon)}
</option>
))}
</select>
</label>
{selectedTaxon ? (
<p className="selected-taxon">
<Check size={15} />
{formatTaxon(selectedTaxon)}
</p>
) : null}
<div className="taxon-results" aria-label="Taxon search results">
{filteredTaxa.length === 0 ? (
<p className="empty-state">No matching taxa.</p>
) : null}
{filteredTaxa.slice(0, 8).map((taxon) => (
<button
className="taxon-option"
key={taxon.id}
type="button"
aria-pressed={String(taxon.id) === form.taxonId}
onClick={() => onSelectTaxon(taxon)}
>
<span>{taxon.name}</span>
<small>{taxon.genus} {taxon.species}</small>
</button>
))}
</div>
</div>
<label>
Location
@@ -244,24 +576,60 @@ export function PlantsView({
onChange={(event) => onFieldChange('location', event.target.value)}
/>
</label>
<label>
Last watered
<input
type="date"
value={form.lastWateredOn}
onChange={(event) => onFieldChange('lastWateredOn', event.target.value)}
/>
</label>
<label>
Water every days
<input
min="1"
max="365"
type="number"
value={form.waterEveryDays}
onChange={(event) => onFieldChange('waterEveryDays', event.target.value)}
/>
</label>
<fieldset className="resource-picker schedule-picker">
<legend>Care schedules</legend>
{enabledCareActions.length === 0 ? (
<p className="empty-state">No enabled actions.</p>
) : null}
{enabledCareActions.map((action) => {
const actionId = String(action.id);
const selectedSchedule = form.careSchedules.find(
(schedule) => schedule.careActionId === actionId,
);
return (
<div className="resource-entry" key={action.id}>
<label className="check-option">
<input
checked={Boolean(selectedSchedule?.isEnabled)}
type="checkbox"
onChange={(event) => {
const existingSchedules = form.careSchedules.filter(
(schedule) => schedule.careActionId !== actionId,
);
const nextSchedule = {
careActionId: actionId,
everyDays: selectedSchedule?.everyDays || '7',
isEnabled: event.target.checked,
};
onFieldChange('careSchedules', [...existingSchedules, nextSchedule]);
}}
/>
<span>{action.name}</span>
</label>
{selectedSchedule?.isEnabled ? (
<div className="resource-amount schedule-interval">
<label>
Every days
<input
min="1"
max="365"
type="number"
value={selectedSchedule.everyDays}
onChange={(event) => onFieldChange(
'careSchedules',
form.careSchedules.map((schedule) => schedule.careActionId === actionId
? { ...schedule, everyDays: event.target.value }
: schedule),
)}
/>
</label>
</div>
) : null}
</div>
);
})}
</fieldset>
</div>
<div className="quick-taxon" aria-labelledby="quick-taxon-heading">
@@ -308,6 +676,7 @@ export function PlantsView({
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="plants-list-heading">
<div className="section-heading">
@@ -319,16 +688,31 @@ export function PlantsView({
<p className="empty-state">No plants yet.</p>
) : null}
{plants.map((plant) => (
{!isLoading && plants.length > 0 && visiblePlants.length === 0 ? (
<p className="empty-state">No plants match the search.</p>
) : null}
{visiblePlants.map((plant) => (
<article className="plant-row" key={plant.id}>
<div className="plant-mark">
<Leaf size={20} />
</div>
<div>
<h3>{plant.nickname}</h3>
<p>{plant.taxon} - {plant.location}</p>
{plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? (
<div className="flag-list">
{plant.flags
.filter((flag) => flag.resolvedOn === null)
.map((flag) => (
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
))}
</div>
) : null}
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${plant.nickname}`} onClick={() => onOpenDetail(plant)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
<Edit3 size={17} />
</button>
@@ -347,7 +731,14 @@ export function PlantsView({
);
}
function formatLogDate(date: string) {
const statusLabel: Record<CareStatus, string> = {
due: 'Due',
soon: 'Soon',
ok: 'Ok',
unscheduled: 'Unscheduled',
};
function formatDate(date: string) {
const [year, month, day] = date.split('-');
if (!year || !month || !day) {
return date;
@@ -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({
</button>
</section>
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="resource-editor-heading">
<div className="section-heading">
<div>
@@ -102,6 +105,7 @@ export function ResourcesView({
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="resources-list-heading">
<div className="section-heading">
@@ -115,9 +119,6 @@ export function ResourcesView({
{resources.map((resource) => (
<article className="plant-row" key={resource.id}>
<div className="plant-mark">
<Package size={20} />
</div>
<div>
<h3>{resource.name}</h3>
<p>
@@ -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({
</button>
</section>
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="taxon-editor-heading">
<div className="section-heading">
<div>
@@ -114,6 +117,7 @@ export function TaxaView({
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="taxa-list-heading">
<div className="section-heading">
@@ -127,9 +131,6 @@ export function TaxaView({
{taxa.map((taxon) => (
<article className="plant-row" key={taxon.id}>
<div className="plant-mark">
<BookOpen size={20} />
</div>
<div>
<h3>{taxon.name}</h3>
<p>{formatTaxon(taxon)}</p>
+76 -8
View File
@@ -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;
};
+72 -8
View File
@@ -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})`;
File diff suppressed because it is too large Load Diff
+185 -26
View File
@@ -6,15 +6,18 @@ namespace plant_manager
string Nickname,
int TaxonId,
string? Location,
DateOnly? LastWateredOn,
int? WaterEveryDays);
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
public record UpdatePlantRequest(
string Nickname,
int TaxonId,
string? Location,
DateOnly? LastWateredOn,
int? WaterEveryDays);
IReadOnlyList<SavePlantCareScheduleRequest>? 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<ActionLogResourceRequest>? Resources);
public record UpdateActionLogRequest(
int PlantId,
int CareActionId,
string? Notes,
DateOnly PerformedOn,
IReadOnlyList<ActionLogResourceRequest>? 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<PlantFlagDto> Flags,
IReadOnlyList<PlantCareScheduleDto> 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<ActionLogResourceDto> 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)
+72 -1
View File
@@ -10,6 +10,10 @@ namespace plant_manager.Data
public DbSet<CareAction> CareActions { get; set; }
public DbSet<ActionResource> ActionResources { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<ActionLogResource> ActionLogResources { get; set; }
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
public DbSet<PlantFlag> PlantFlags { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -65,12 +69,79 @@ namespace plant_manager.Data
modelBuilder.Entity<ActionLog>(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<ActionLogResource>(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<PlantCareSchedule>(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<PlantFlagDefinition>(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<PlantFlag>(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);
});
}
}
+122 -56
View File
@@ -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();
}
@@ -0,0 +1,415 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionNameSnapshot")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateOnly>("PerformedOn")
.HasColumnType("TEXT");
b.Property<int>("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<int>("ActionLogId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int>("TaxonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TaxonId");
b.ToTable("Plants");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantFlagDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<DateOnly?>("ResolvedOn")
.HasColumnType("TEXT");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Authority")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Cultivar")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Genus")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Species")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,304 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace plant_manager.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ActionResources",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ActionResources", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CareActions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 400, nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareActions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PlantFlagDefinitions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
Color = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PlantTaxa",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Genus = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Species = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Cultivar = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
Variety = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
Authority = table.Column<string>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TaxonId = table.Column<int>(type: "INTEGER", nullable: false),
Nickname = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Location = table.Column<string>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
ActionNameSnapshot = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
PerformedOn = table.Column<DateOnly>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
IsEnabled = table.Column<bool>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
PlantFlagDefinitionId = table.Column<int>(type: "INTEGER", nullable: false),
Severity = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
StartedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
ResolvedOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
Notes = table.Column<string>(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<int>(type: "INTEGER", nullable: false),
ActionResourceId = table.Column<int>(type: "INTEGER", nullable: false),
Quantity = table.Column<decimal>(type: "TEXT", precision: 10, scale: 2, nullable: true),
Unit = table.Column<string>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,412 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionNameSnapshot")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateOnly>("PerformedOn")
.HasColumnType("TEXT");
b.Property<int>("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<int>("ActionLogId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int>("TaxonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TaxonId");
b.ToTable("Plants");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantFlagDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<DateOnly?>("ResolvedOn")
.HasColumnType("TEXT");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Authority")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Cultivar")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Genus")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Species")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
+4 -1
View File
@@ -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<ActionLogResource> Resources { get; set; } = [];
}
}
@@ -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!;
}
}
@@ -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<ActionLogResource> ActionLogResources { get; set; } = [];
}
}
+3
View File
@@ -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<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
}
}
+2 -2
View File
@@ -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<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> CareSchedules { get; set; } = [];
public List<PlantFlag> Flags { get; set; } = [];
}
}
@@ -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!;
}
}
+16
View File
@@ -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!;
}
}
@@ -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<PlantFlag> PlantFlags { get; set; } = [];
}
}
+133 -18
View File
@@ -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<ActionLogResource> Resources, string? Error)> BuildLogResources(
IReadOnlyList<ActionLogResourceRequest>? 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);
}
}
}
@@ -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();
@@ -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;
+28 -7
View File
@@ -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<IResult> 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);
}
}
}
+98 -7
View File
@@ -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<string?> ApplyCareSchedules(
Plant plant,
IReadOnlyList<SavePlantCareScheduleRequest>? 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;
}
}
}
@@ -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<string> 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();
}
}
+1 -1
View File
@@ -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"
}
}));
+2 -1
View File
@@ -35,7 +35,7 @@ app.UseCors(frontendPolicy);
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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();
+4
View File
@@ -8,6 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>