add care history editing and snooze

This commit is contained in:
2026-06-14 23:31:12 +00:00
parent cc6f40222e
commit 7a11e68c17
20 changed files with 1162 additions and 58 deletions
+24
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { ActionsView } from './components/ActionsView';
import { ActivitiesView } from './components/ActivitiesView';
import { CareView } from './components/CareView';
import { CareHistoryView } from './components/CareHistoryView';
import { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView';
@@ -23,6 +24,7 @@ export function App() {
actionResources,
careActions,
careActivities,
careHistory,
careTasks,
dueCount,
error,
@@ -159,6 +161,7 @@ export function App() {
removeAction,
removeActivity,
removeAssignedPlantFlag,
removeCareHistoryEvent,
removeSchedule,
removeFlagDefinition,
removeLocation,
@@ -180,6 +183,8 @@ export function App() {
searchTaxonInfo,
previewCatalogImportFile,
setPlantInfoQuery,
snoozeCare,
updateCareHistoryEvent,
} = useAppActions({
editors,
loadDashboard,
@@ -244,6 +249,13 @@ export function App() {
>
Care
</button>
<button
type="button"
aria-current={view === 'care-history' ? 'page' : undefined}
onClick={() => setView('care-history')}
>
Care History
</button>
<button
type="button"
aria-current={view === 'schedules' ? 'page' : undefined}
@@ -393,6 +405,18 @@ export function App() {
plants={plants}
onDismissCare={(payload) => void dismissCare(payload)}
onLogCare={(payload, requireDueSchedule) => void logCare(payload, requireDueSchedule)}
onSnoozeCare={(payload) => void snoozeCare(payload)}
/>
) : view === 'care-history' ? (
<CareHistoryView
activities={careActivities}
events={careHistory}
error={error}
isLoading={isLoading}
isSaving={isSaving}
plants={plants}
onDelete={(event) => void removeCareHistoryEvent(event)}
onUpdate={(event, payload) => void updateCareHistoryEvent(event, payload)}
/>
) : view === 'taxa' ? (
<TaxaView
+55
View File
@@ -7,9 +7,11 @@ import type {
CareActionPayload,
CatalogImportResult,
BulkCompleteCareTasksPayload,
CareHistoryEvent,
CareTask,
BulkPlantCareSchedulePayload,
DismissCareTasksPayload,
SnoozeCareTasksPayload,
Recipe,
RecipePayload,
Plant,
@@ -25,6 +27,9 @@ import type {
PlantLocation,
PlantLocationPayload,
PlantTaxon,
UpdateActionLogPayload,
UpdateCareDismissalPayload,
UpdateCareSnoozePayload,
} from './domain';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
@@ -334,6 +339,10 @@ export async function getCareTasks() {
return request<CareTask[]>('/api/care-tasks/upcoming');
}
export async function getCareHistory() {
return request<CareHistoryEvent[]>('/api/care-history');
}
export async function getPlantCareSchedules() {
return request<PlantCareScheduleRule[]>('/api/plant-care-schedules');
}
@@ -372,9 +381,55 @@ export async function dismissCareTasksBulk(payload: DismissCareTasksPayload) {
});
}
export async function snoozeCareTasksBulk(payload: SnoozeCareTasksPayload) {
return request<{ snoozed: number }>('/api/care-tasks/snooze-bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function createActionLogsBulk(payload: BulkCompleteCareTasksPayload) {
return request<{ completed: number }>('/api/action-logs/bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateActionLog(id: number, payload: UpdateActionLogPayload) {
return request<void>(`/api/action-logs/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteActionLog(id: number) {
return request<void>(`/api/action-logs/${id}`, {
method: 'DELETE',
});
}
export async function updateCareDismissal(id: number, payload: UpdateCareDismissalPayload) {
return request<void>(`/api/care-dismissals/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteCareDismissal(id: number) {
return request<void>(`/api/care-dismissals/${id}`, {
method: 'DELETE',
});
}
export async function updateCareSnooze(id: number, payload: UpdateCareSnoozePayload) {
return request<void>(`/api/care-snoozes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteCareSnooze(id: number) {
return request<void>(`/api/care-snoozes/${id}`, {
method: 'DELETE',
});
}
@@ -0,0 +1,345 @@
import { useMemo, useState } from 'react';
import type { CareActivity, CareHistoryEvent, Plant, UpdateActionLogPayload, UpdateCareDismissalPayload, UpdateCareSnoozePayload } from '../domain';
import { SummaryStrip } from './Ui';
type CareHistoryViewProps = {
activities: CareActivity[];
events: CareHistoryEvent[];
error: string | null;
isLoading: boolean;
isSaving: boolean;
plants: Plant[];
onDelete: (event: CareHistoryEvent) => void;
onUpdate: (event: CareHistoryEvent, payload: UpdateActionLogPayload | UpdateCareDismissalPayload | UpdateCareSnoozePayload) => void;
};
type EventTypeFilter = 'all' | CareHistoryEvent['type'];
export function CareHistoryView({
activities,
events,
error,
isLoading,
isSaving,
plants,
onDelete,
onUpdate,
}: CareHistoryViewProps) {
const [typeFilter, setTypeFilter] = useState<EventTypeFilter>('all');
const [plantId, setPlantId] = useState('');
const [activityId, setActivityId] = useState('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [query, setQuery] = useState('');
const [editingKey, setEditingKey] = useState('');
const [editDate, setEditDate] = useState('');
const [editNotes, setEditNotes] = useState('');
const [editResources, setEditResources] = useState<Record<number, { quantity: string; unit: string }>>({});
const filteredEvents = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return events.filter((event) => {
if (typeFilter !== 'all' && event.type !== typeFilter) {
return false;
}
if (plantId && event.plantId !== Number(plantId)) {
return false;
}
if (activityId && event.careActivityId !== Number(activityId)) {
return false;
}
if (fromDate && event.date < fromDate) {
return false;
}
if (toDate && event.date > toDate) {
return false;
}
if (!normalizedQuery) {
return true;
}
return [
event.plantName,
event.action,
event.notes ?? '',
...event.resources.map((resource) => resource.name),
].join(' ').toLowerCase().includes(normalizedQuery);
});
}, [activityId, events, fromDate, plantId, query, toDate, typeFilter]);
return (
<>
<SummaryStrip ariaLabel="Care history summary">
<p>{error ?? 'Review logged, dismissed, and snoozed care events.'}</p>
</SummaryStrip>
<section className="work-panel" aria-labelledby="care-history-filters-heading">
<div className="section-heading">
<h2 id="care-history-filters-heading">Care History</h2>
<span className="schedule-count">{filteredEvents.length} shown</span>
</div>
<div className="plant-form">
<label>
Type
<select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as EventTypeFilter)}>
<option value="all">All events</option>
<option value="log">Logged</option>
<option value="dismissal">Dismissed</option>
<option value="snooze">Snoozed</option>
</select>
</label>
<label>
Plant
<select value={plantId} onChange={(event) => setPlantId(event.target.value)}>
<option value="">All plants</option>
{plants.map((plant) => (
<option key={plant.id} value={plant.id}>
{plant.nickname}
</option>
))}
</select>
</label>
<label>
Activity
<select value={activityId} onChange={(event) => setActivityId(event.target.value)}>
<option value="">All activities</option>
{activities.map((activity) => (
<option key={activity.id} value={activity.id}>
{activity.name}
</option>
))}
</select>
</label>
<label>
Search
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<label>
From
<input
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
/>
</label>
<label>
To
<input
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
/>
</label>
</div>
</section>
<section className="work-panel" aria-labelledby="care-history-list-heading">
<div className="section-heading">
<h2 id="care-history-list-heading">Events</h2>
</div>
<div className="detail-list">
{!isLoading && filteredEvents.length === 0 ? (
<p className="empty-state">{events.length === 0 ? 'No care history yet.' : 'No events match these filters.'}</p>
) : null}
{filteredEvents.map((event) => {
const eventKey = `${event.type}-${event.id}`;
const isEditing = editingKey === eventKey;
return (
<article className="detail-row care-history-row" key={eventKey}>
<div>
<h4>{event.action}</h4>
<p>{event.plantName} - {formatDate(event.date)}</p>
{event.notes ? <p>{event.notes}</p> : null}
{event.resources.length > 0 ? (
<p>{event.resources.map(formatResource).join(', ')}</p>
) : null}
{isEditing ? (
<div className="care-history-editor">
<label>
{event.type === 'snooze' ? 'Snoozed until' : 'Date'}
<input
disabled={isSaving}
type="date"
value={editDate}
onChange={(changeEvent) => setEditDate(changeEvent.target.value)}
/>
</label>
<label>
Notes
<textarea
disabled={isSaving}
value={editNotes}
onChange={(changeEvent) => setEditNotes(changeEvent.target.value)}
/>
</label>
{event.type === 'log' && event.resources.length > 0 ? (
<div className="history-resource-grid">
{event.resources.map((resource) => (
<div className="care-resource-edit" key={resource.actionResourceId}>
<span>{resource.name}</span>
<input
aria-label={`${resource.name} quantity`}
disabled={isSaving}
type="number"
min="0"
value={editResources[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity)}
onChange={(changeEvent) => setEditResources((current) => ({
...current,
[resource.actionResourceId]: {
quantity: changeEvent.target.value,
unit: current[resource.actionResourceId]?.unit ?? resource.unit ?? '',
},
}))}
/>
<input
aria-label={`${resource.name} unit`}
disabled={isSaving}
value={editResources[resource.actionResourceId]?.unit ?? resource.unit ?? ''}
onChange={(changeEvent) => setEditResources((current) => ({
...current,
[resource.actionResourceId]: {
quantity: current[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity),
unit: changeEvent.target.value,
},
}))}
/>
</div>
))}
</div>
) : null}
<div className="form-actions">
<button
className="primary-action"
type="button"
disabled={isSaving || !editDate}
onClick={() => saveEdit(event)}
>
Save
</button>
<button
className="text-button"
type="button"
disabled={isSaving}
onClick={() => setEditingKey('')}
>
Cancel
</button>
</div>
</div>
) : null}
</div>
<div className="history-event-actions">
<span className={`status-pill ${event.type === 'log' ? 'ok' : event.type === 'snooze' ? 'soon' : 'unscheduled'}`}>
{formatEventType(event.type)}
</span>
<div className="row-actions">
<button
className="text-button"
type="button"
disabled={isSaving}
onClick={() => startEdit(event)}
>
Edit
</button>
<button
className="text-button"
type="button"
disabled={isSaving}
onClick={() => onDelete(event)}
>
Delete
</button>
</div>
</div>
</article>
);
})}
</div>
</section>
</>
);
function startEdit(event: CareHistoryEvent) {
setEditingKey(`${event.type}-${event.id}`);
setEditDate(event.date);
setEditNotes(event.notes ?? '');
setEditResources(Object.fromEntries(event.resources.map((resource) => [
resource.actionResourceId,
{
quantity: formatNumber(resource.quantity),
unit: resource.unit ?? '',
},
])));
}
function saveEdit(event: CareHistoryEvent) {
const notes = editNotes.trim() || null;
if (event.type === 'log') {
onUpdate(event, {
plantId: event.plantId,
careActivityId: event.careActivityId,
performedOn: editDate,
notes,
resources: event.resources.map((resource) => ({
actionResourceId: resource.actionResourceId,
quantity: normalizeQuantity(editResources[resource.actionResourceId]?.quantity, resource.quantity),
unit: editResources[resource.actionResourceId]?.unit.trim() || resource.unit,
})),
});
} else if (event.type === 'dismissal') {
onUpdate(event, {
plantId: event.plantId,
careActivityId: event.careActivityId,
dismissedOn: editDate,
notes,
});
} else {
onUpdate(event, {
plantId: event.plantId,
careActivityId: event.careActivityId,
snoozedUntil: editDate,
notes,
});
}
setEditingKey('');
}
}
function formatResource(resource: CareHistoryEvent['resources'][number]) {
const quantity = resource.quantity === null ? '' : `${resource.quantity} `;
const unit = resource.unit ? `${resource.unit} ` : '';
return `${quantity}${unit}${resource.name}`.trim();
}
function formatDate(value: string) {
const [year, month, day] = value.split('-');
if (!year || !month || !day) {
return value;
}
return `${month}/${day}/${year}`;
}
function formatEventType(type: CareHistoryEvent['type']) {
return type === 'log' ? 'Logged' : type === 'dismissal' ? 'Dismissed' : 'Snoozed';
}
function formatNumber(value: number | null) {
return value === null ? '' : String(value);
}
function normalizeQuantity(value: string | undefined, fallback: number | null) {
if (value === undefined || value.trim() === '') {
return fallback;
}
return Number(value);
}
+55 -9
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import type { BulkCompleteCareTasksPayload, CareActivity, CareActivityActionResource, CareActivityRecipeComponent, CareTask, DismissCareTasksPayload, Plant } from '../domain';
import type { BulkCompleteCareTasksPayload, CareActivity, CareActivityActionResource, CareActivityRecipeComponent, CareTask, DismissCareTasksPayload, Plant, SnoozeCareTasksPayload } from '../domain';
import { SummaryStrip } from './Ui';
type CareMode = 'due' | 'upcoming' | 'adHoc';
@@ -13,6 +13,7 @@ type CareViewProps = {
plants: Plant[];
onDismissCare: (payload: DismissCareTasksPayload) => void;
onLogCare: (payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) => void;
onSnoozeCare: (payload: SnoozeCareTasksPayload) => void;
};
const modeOptions: { value: CareMode; label: string }[] = [
@@ -30,12 +31,14 @@ export function CareView({
plants,
onDismissCare,
onLogCare,
onSnoozeCare,
}: CareViewProps) {
const [mode, setMode] = useState<CareMode>('due');
const [activityId, setActivityId] = useState('');
const [selectedPlantIds, setSelectedPlantIds] = useState<string[]>([]);
const [plantQuery, setPlantQuery] = useState('');
const [performedOn, setPerformedOn] = useState(() => new Date().toISOString().slice(0, 10));
const [snoozedUntil, setSnoozedUntil] = useState(() => getTomorrowInputDate());
const [notes, setNotes] = useState('');
const [resourceEdits, setResourceEdits] = useState<Record<number, { quantity: string; unit: string }>>({});
@@ -111,6 +114,22 @@ export function CareView({
setNotes('');
}
function snooze() {
if (!selectedActivity) {
return;
}
onSnoozeCare({
careActivityId: selectedActivity.id,
plantIds: selectedPlantIds.map((id) => Number(id)),
snoozedUntil,
notes: notes.trim() || null,
});
setSelectedPlantIds([]);
setNotes('');
}
return (
<>
<SummaryStrip ariaLabel="Care summary">
@@ -167,6 +186,17 @@ export function CareView({
onChange={(event) => setPerformedOn(event.target.value)}
/>
</label>
{mode === 'due' ? (
<label>
Snooze until
<input
disabled={isSaving}
type="date"
value={snoozedUntil}
onChange={(event) => setSnoozedUntil(event.target.value)}
/>
</label>
) : null}
<label>
Search plants
<input
@@ -309,14 +339,24 @@ export function CareView({
{isSaving ? 'Saving' : 'Log'}
</button>
{mode === 'due' ? (
<button
className="text-button"
type="button"
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
onClick={dismiss}
>
Dismiss
</button>
<>
<button
className="text-button"
type="button"
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
onClick={snooze}
>
Snooze
</button>
<button
className="text-button"
type="button"
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
onClick={dismiss}
>
Dismiss
</button>
</>
) : null}
</div>
</section>
@@ -374,3 +414,9 @@ function formatRecipeComponent(component: CareActivityRecipeComponent) {
function formatNumber(value: number | null) {
return value === null ? '' : String(value);
}
function getTomorrowInputDate() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().slice(0, 10);
}
+41
View File
@@ -260,6 +260,18 @@ export type ActionLogResource = {
unit: string | null;
};
export type CareHistoryEvent = {
id: number;
type: 'log' | 'dismissal' | 'snooze';
plantId: number;
plantName: string;
careActivityId: number;
action: string;
date: string;
notes: string | null;
resources: ActionLogResource[];
};
export type PlantPayload = {
nickname: string;
birthday: string | null;
@@ -375,12 +387,41 @@ export type DismissCareTasksPayload = {
dismissedOn: string | null;
};
export type SnoozeCareTasksPayload = {
careActivityId: number;
plantIds: number[];
notes: string | null;
snoozedUntil: string;
};
export type CareLogResourcePayload = {
actionResourceId: number;
quantity: number | null;
unit: string | null;
};
export type UpdateActionLogPayload = {
plantId: number;
careActivityId: number;
notes: string | null;
performedOn: string;
resources: CareLogResourcePayload[];
};
export type UpdateCareDismissalPayload = {
plantId: number;
careActivityId: number;
notes: string | null;
dismissedOn: string;
};
export type UpdateCareSnoozePayload = {
plantId: number;
careActivityId: number;
notes: string | null;
snoozedUntil: string;
};
export type CareTask = {
id: number;
plantId: number;
+1 -1
View File
@@ -121,7 +121,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
export type View = 'home' | 'plant-management' | 'care' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
export type View = 'home' | 'plant-management' | 'care' | 'care-history' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
export function toPlantForm(plant: Plant): PlantFormState {
return {
+26
View File
@@ -1263,6 +1263,32 @@ dd {
resize: vertical;
}
.care-history-row {
align-items: start;
}
.history-event-actions {
display: grid;
justify-items: end;
gap: 8px;
justify-self: end;
}
.history-event-actions > span {
white-space: nowrap;
}
.care-history-editor,
.history-resource-grid {
display: grid;
gap: 10px;
margin-top: 12px;
}
.history-resource-grid .care-resource-edit {
grid-template-columns: minmax(100px, 1fr) minmax(80px, 120px) minmax(80px, 120px);
}
.dashboard-panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+73 -1
View File
@@ -13,9 +13,12 @@ import {
createPlantGroup,
createPlantLocation,
createRecipe,
deleteActionLog,
deleteActionResource,
deleteCareAction,
deleteCareActivity,
deleteCareDismissal,
deleteCareSnooze,
deletePlant,
deletePlantCareSchedule,
deletePlantFlag,
@@ -30,9 +33,13 @@ import {
removePlantFlagAssignment,
resolvePlantFlag,
searchPlantInfo,
snoozeCareTasksBulk,
updateActionLog,
updateActionResource,
updateCareAction,
updateCareActivity,
updateCareDismissal,
updateCareSnooze,
updatePlant,
updatePlantCareSchedule,
updatePlantFlag,
@@ -40,7 +47,7 @@ import {
updatePlantLocation,
updateRecipe,
} from './api';
import type { BulkCompleteCareTasksPayload, CareTask, CatalogImportResult, DismissCareTasksPayload, Plant, PlantCareScheduleRule, PlantFlag, PlantInfoSearchResult, PlantTaxon } from './domain';
import type { BulkCompleteCareTasksPayload, CareHistoryEvent, CareTask, CatalogImportResult, DismissCareTasksPayload, Plant, PlantCareScheduleRule, PlantFlag, PlantInfoSearchResult, PlantTaxon, SnoozeCareTasksPayload, UpdateActionLogPayload, UpdateCareDismissalPayload, UpdateCareSnoozePayload } from './domain';
import {
emptyPlantFlagForm,
toActionPayload,
@@ -787,6 +794,64 @@ export function useAppActions({
}
}
async function snoozeCare(payload: SnoozeCareTasksPayload) {
if (payload.plantIds.length === 0 || !payload.careActivityId || !payload.snoozedUntil) {
setError('Select an activity, at least one plant, and a snooze date.');
return;
}
setIsSaving(true);
try {
await snoozeCareTasksBulk(payload);
await loadPlantsAndCareTasks();
} catch {
setError('Could not snooze care.');
} finally {
setIsSaving(false);
}
}
async function updateCareHistoryEvent(event: CareHistoryEvent, payload: UpdateActionLogPayload | UpdateCareDismissalPayload | UpdateCareSnoozePayload) {
setIsSaving(true);
try {
if (event.type === 'log') {
await updateActionLog(event.id, payload as UpdateActionLogPayload);
} else if (event.type === 'dismissal') {
await updateCareDismissal(event.id, payload as UpdateCareDismissalPayload);
} else {
await updateCareSnooze(event.id, payload as UpdateCareSnoozePayload);
}
await loadPlantsAndCareTasks();
} catch {
setError('Could not update care history.');
} finally {
setIsSaving(false);
}
}
async function removeCareHistoryEvent(event: CareHistoryEvent) {
const confirmed = window.confirm(`Delete this ${formatCareHistoryType(event.type)} event?`);
if (!confirmed) {
return;
}
setIsSaving(true);
try {
if (event.type === 'log') {
await deleteActionLog(event.id);
} else if (event.type === 'dismissal') {
await deleteCareDismissal(event.id);
} else {
await deleteCareSnooze(event.id);
}
await loadPlantsAndCareTasks();
} catch {
setError('Could not delete care history.');
} finally {
setIsSaving(false);
}
}
return {
assignFlagToSelectedPlant,
applyCatalogImportFile,
@@ -801,6 +866,7 @@ export function useAppActions({
isSearchingPlantInfo,
isSaving,
logCare,
removeCareHistoryEvent,
importTaxonFromPlantInfo,
plantInfoQuery,
plantInfoResults,
@@ -828,9 +894,15 @@ export function useAppActions({
searchTaxonInfo,
previewCatalogImportFile,
setPlantInfoQuery,
snoozeCare,
updateCareHistoryEvent,
};
}
function getTodayInputDate() {
return new Date().toISOString().slice(0, 10);
}
function formatCareHistoryType(type: CareHistoryEvent['type']) {
return type === 'log' ? 'logged care' : type === 'dismissal' ? 'dismissed care' : 'snoozed care';
}
+13 -1
View File
@@ -4,6 +4,7 @@ import {
getCareActivities,
getCareActions,
getCareTasks,
getCareHistory,
getPlants,
getPlantCareSchedules,
getPlantFlags,
@@ -16,6 +17,7 @@ import type {
ActionResource,
CareActivity,
CareAction,
CareHistoryEvent,
CareTask,
Plant,
PlantCareScheduleRule,
@@ -38,6 +40,7 @@ export function useDashboardData() {
const [plantCareSchedules, setPlantCareSchedules] = useState<PlantCareScheduleRule[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
const [careHistory, setCareHistory] = useState<CareHistoryEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -46,6 +49,7 @@ export function useDashboardData() {
const [
plantsResponse,
tasksResponse,
historyResponse,
schedulesResponse,
taxaResponse,
locationsResponse,
@@ -58,6 +62,7 @@ export function useDashboardData() {
] = await Promise.all([
getPlants(),
getCareTasks(),
getCareHistory(),
getPlantCareSchedules(),
getPlantTaxa(),
getPlantLocations(),
@@ -72,6 +77,7 @@ export function useDashboardData() {
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
setCareHistory(historyResponse);
setPlantCareSchedules(schedulesResponse);
setPlantTaxa(taxaResponse);
setPlantLocations(locationsResponse);
@@ -114,15 +120,17 @@ export function useDashboardData() {
}
async function loadPlantsAndCareTasks() {
const [plantsResponse, tasksResponse, schedulesResponse] = await Promise.all([
const [plantsResponse, tasksResponse, historyResponse, schedulesResponse] = await Promise.all([
getPlants(),
getCareTasks(),
getCareHistory(),
getPlantCareSchedules(),
]);
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
setCareHistory(historyResponse);
setPlantCareSchedules(schedulesResponse);
}
@@ -152,6 +160,7 @@ export function useDashboardData() {
const [
plantsResponse,
tasksResponse,
historyResponse,
schedulesResponse,
actionsResponse,
resourcesResponse,
@@ -160,6 +169,7 @@ export function useDashboardData() {
] = await Promise.all([
getPlants(),
getCareTasks(),
getCareHistory(),
getPlantCareSchedules(),
getCareActions(),
getActionResources(),
@@ -170,6 +180,7 @@ export function useDashboardData() {
setError(null);
setPlants(plantsResponse);
setCareTasks(tasksResponse);
setCareHistory(historyResponse);
setPlantCareSchedules(schedulesResponse);
setCareActions(actionsResponse);
setActionResources(resourcesResponse);
@@ -203,6 +214,7 @@ export function useDashboardData() {
actionResources,
careActions,
careActivities,
careHistory,
careTasks,
dueCount,
error,
+96 -4
View File
@@ -222,6 +222,24 @@ namespace plant_manager
DateOnly? DismissedOn,
string? Notes);
public record SnoozeCareTasksRequest(
int CareActivityId,
IReadOnlyList<int> PlantIds,
DateOnly SnoozedUntil,
string? Notes);
public record UpdateCareDismissalRequest(
int PlantId,
int CareActivityId,
DateOnly DismissedOn,
string? Notes);
public record UpdateCareSnoozeRequest(
int PlantId,
int CareActivityId,
DateOnly SnoozedUntil,
string? Notes);
public record CatalogImportIssue(
string Sheet,
int Row,
@@ -453,7 +471,8 @@ namespace plant_manager
return PlantCareFormatter.GetNextCareDate(
source,
schedule.LastPerformedOn,
GetCompletedOccurrences(plant, source.CareActivityId));
GetCompletedOccurrences(plant, source.CareActivityId),
GetSnoozedUntil(plant, source.CareActivityId));
})
.Where(date => date is not null)
.OrderBy(date => date)
@@ -499,6 +518,12 @@ namespace plant_manager
private static int GetCompletedOccurrences(Plant plant, int careActivityId) =>
plant.ActionLogs.Count(log => log.CareActivityId == careActivityId)
+ plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == careActivityId);
private static DateOnly? GetSnoozedUntil(Plant plant, int careActivityId) =>
plant.CareSnoozes
.Where(snooze => snooze.CareActivityId == careActivityId)
.Select(snooze => (DateOnly?)snooze.SnoozedUntil)
.Max();
}
public record PlantCareScheduleDto(
@@ -526,7 +551,11 @@ namespace plant_manager
DateOnly today)
{
var schedule = assignment.PlantCareSchedule;
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(assignment));
var nextCare = PlantCareFormatter.GetNextCareDate(
schedule,
lastPerformedOn,
GetCompletedOccurrences(assignment),
GetSnoozedUntil(assignment));
return new PlantCareScheduleDto(
schedule.Id,
@@ -551,6 +580,12 @@ namespace plant_manager
private static int GetCompletedOccurrences(PlantCareScheduleAssignment assignment) =>
assignment.Plant.ActionLogs.Count(log => log.CareActivityId == assignment.PlantCareSchedule.CareActivityId)
+ assignment.Plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == assignment.PlantCareSchedule.CareActivityId);
private static DateOnly? GetSnoozedUntil(PlantCareScheduleAssignment assignment) =>
assignment.Plant.CareSnoozes
.Where(snooze => snooze.CareActivityId == assignment.PlantCareSchedule.CareActivityId)
.Select(snooze => (DateOnly?)snooze.SnoozedUntil)
.Max();
}
public record PlantCareScheduleAssignmentDto(
@@ -628,10 +663,11 @@ namespace plant_manager
PlantCareScheduleAssignment assignment,
DateOnly? lastPerformedOn,
int completedOccurrences,
DateOnly? snoozedUntil,
DateOnly today)
{
var schedule = assignment.PlantCareSchedule;
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences, snoozedUntil);
return new CareTaskDto(
schedule.Id,
@@ -718,12 +754,63 @@ namespace plant_manager
resource.Unit);
}
public record CareHistoryEventDto(
int Id,
string Type,
int PlantId,
string PlantName,
int CareActivityId,
string Action,
DateOnly Date,
string? Notes,
IReadOnlyList<ActionLogResourceDto> Resources)
{
public static CareHistoryEventDto FromActionLog(ActionLog log) =>
new(
log.Id,
"log",
log.PlantId,
log.Plant.Nickname,
log.CareActivityId,
log.ActionNameSnapshot,
log.PerformedOn,
log.Notes,
log.Resources
.Select(ActionLogResourceDto.FromActionLogResource)
.ToList());
public static CareHistoryEventDto FromDismissal(CareDismissal dismissal) =>
new(
dismissal.Id,
"dismissal",
dismissal.PlantId,
dismissal.Plant.Nickname,
dismissal.CareActivityId,
dismissal.CareActivity.Name,
dismissal.DismissedOn,
dismissal.Notes,
[]);
public static CareHistoryEventDto FromSnooze(CareSnooze snooze) =>
new(
snooze.Id,
"snooze",
snooze.PlantId,
snooze.Plant.Nickname,
snooze.CareActivityId,
snooze.CareActivity.Name,
snooze.SnoozedUntil,
snooze.Notes,
[]);
}
internal static class PlantCareFormatter
{
public static DateOnly? GetNextCareDate(
PlantCareSchedule schedule,
DateOnly? lastPerformedOn,
int completedOccurrences = 0)
int completedOccurrences = 0,
DateOnly? snoozedUntil = null)
{
if (schedule.EndsMode == "after" && schedule.EndsAfterOccurrences is not null && completedOccurrences >= schedule.EndsAfterOccurrences)
{
@@ -746,6 +833,11 @@ namespace plant_manager
nextCare = AddInterval(lastPerformedOn.Value, schedule);
}
if (nextCare is not null && snoozedUntil is not null && snoozedUntil > nextCare)
{
nextCare = snoozedUntil;
}
if (nextCare is not null && schedule.EndsMode == "on" && schedule.EndsOn is not null && nextCare > schedule.EndsOn)
{
return null;
@@ -16,6 +16,7 @@ namespace plant_manager.Data
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<ActionLogResource> ActionLogResources { get; set; }
public DbSet<CareDismissal> CareDismissals { get; set; }
public DbSet<CareSnooze> CareSnoozes { get; set; }
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
public DbSet<PlantCareScheduleAssignment> PlantCareScheduleAssignments { get; set; }
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
@@ -184,6 +185,23 @@ namespace plant_manager.Data
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<CareSnooze>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasIndex(e => new { e.PlantId, e.CareActivityId, e.SnoozedUntil });
entity.HasOne(e => e.Plant)
.WithMany(e => e.CareSnoozes)
.HasForeignKey(e => e.PlantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CareActivity)
.WithMany(e => e.CareSnoozes)
.HasForeignKey(e => e.CareActivityId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<PlantCareSchedule>(entity =>
{
entity.HasKey(e => e.Id);
@@ -11,7 +11,7 @@ using plant_manager.Data;
namespace plant_manager.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260614222157_InitialCreate")]
[Migration("20260614225310_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
@@ -229,6 +229,37 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareDismissals");
});
modelBuilder.Entity("plant_manager.Data.Models.CareSnooze", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("SnoozedUntil")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActivityId", "SnoozedUntil");
b.ToTable("CareSnoozes");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Property<int>("Id")
@@ -674,6 +705,25 @@ namespace plant_manager.Data.Migrations
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.CareSnooze", b =>
{
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("CareSnoozes")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSnoozes")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareActivity");
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -829,6 +879,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareDismissals");
b.Navigation("CareSnoozes");
b.Navigation("PlantCareSchedules");
});
@@ -845,6 +897,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareScheduleAssignments");
b.Navigation("CareSnoozes");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
@@ -347,6 +347,35 @@ namespace plant_manager.Data.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareSnoozes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
SnoozedUntil = table.Column<DateOnly>(type: "TEXT", nullable: false),
CreatedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CareSnoozes", x => x.Id);
table.ForeignKey(
name: "FK_CareSnoozes_CareActivities_CareActivityId",
column: x => x.CareActivityId,
principalTable: "CareActivities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CareSnoozes_Plants_PlantId",
column: x => x.PlantId,
principalTable: "Plants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PlantCareScheduleAssignments",
columns: table => new
@@ -514,6 +543,16 @@ namespace plant_manager.Data.Migrations
table: "CareDismissals",
columns: new[] { "PlantId", "CareActivityId", "DismissedOn" });
migrationBuilder.CreateIndex(
name: "IX_CareSnoozes_CareActivityId",
table: "CareSnoozes",
column: "CareActivityId");
migrationBuilder.CreateIndex(
name: "IX_CareSnoozes_PlantId_CareActivityId_SnoozedUntil",
table: "CareSnoozes",
columns: new[] { "PlantId", "CareActivityId", "SnoozedUntil" });
migrationBuilder.CreateIndex(
name: "IX_PlantCareScheduleAssignments_PlantId_PlantCareScheduleId",
table: "PlantCareScheduleAssignments",
@@ -615,6 +654,9 @@ namespace plant_manager.Data.Migrations
migrationBuilder.DropTable(
name: "CareDismissals");
migrationBuilder.DropTable(
name: "CareSnoozes");
migrationBuilder.DropTable(
name: "PlantCareScheduleAssignments");
@@ -226,6 +226,37 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareDismissals");
});
modelBuilder.Entity("plant_manager.Data.Models.CareSnooze", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<DateOnly>("SnoozedUntil")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActivityId", "SnoozedUntil");
b.ToTable("CareSnoozes");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Property<int>("Id")
@@ -671,6 +702,25 @@ namespace plant_manager.Data.Migrations
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.CareSnooze", b =>
{
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("CareSnoozes")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSnoozes")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareActivity");
b.Navigation("Plant");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
@@ -826,6 +876,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareDismissals");
b.Navigation("CareSnoozes");
b.Navigation("PlantCareSchedules");
});
@@ -842,6 +894,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareScheduleAssignments");
b.Navigation("CareSnoozes");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
@@ -10,6 +10,7 @@ namespace plant_manager.Data.Models
public List<CareActivityAction> Actions { get; set; } = [];
public List<ActionLog> ActionLogs { get; set; } = [];
public List<CareDismissal> CareDismissals { get; set; } = [];
public List<CareSnooze> CareSnoozes { get; set; } = [];
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace plant_manager.Data.Models
{
public class CareSnooze
{
public int Id { get; set; }
public int PlantId { get; set; }
public int CareActivityId { get; set; }
public DateOnly SnoozedUntil { get; set; }
public DateOnly CreatedOn { get; set; }
public string? Notes { get; set; }
public Plant Plant { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!;
}
}
+1
View File
@@ -12,6 +12,7 @@ namespace plant_manager.Data.Models
public PlantLocation? Location { get; set; }
public List<ActionLog> ActionLogs { get; set; } = [];
public List<CareDismissal> CareDismissals { get; set; } = [];
public List<CareSnooze> CareSnoozes { get; set; } = [];
public List<PlantCareScheduleAssignment> CareScheduleAssignments { get; set; } = [];
public List<PlantFlag> Flags { get; set; } = [];
public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
@@ -22,6 +22,30 @@ namespace plant_manager.Endpoints
return Results.Ok(logs.Select(ActionLogDto.FromActionLog));
});
app.MapGet("/api/care-history", async (ApplicationDbContext db) =>
{
var logs = await db.ActionLogs
.Include(log => log.Plant)
.Include(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.ToListAsync();
var dismissals = await db.CareDismissals
.Include(dismissal => dismissal.Plant)
.Include(dismissal => dismissal.CareActivity)
.ToListAsync();
var snoozes = await db.CareSnoozes
.Include(snooze => snooze.Plant)
.Include(snooze => snooze.CareActivity)
.ToListAsync();
return Results.Ok(logs.Select(CareHistoryEventDto.FromActionLog)
.Concat(dismissals.Select(CareHistoryEventDto.FromDismissal))
.Concat(snoozes.Select(CareHistoryEventDto.FromSnooze))
.OrderByDescending(item => item.Date)
.ThenByDescending(item => item.Id)
.ToList());
});
app.MapPost("/api/action-logs", async (CreateActionLogRequest request, ApplicationDbContext db) =>
{
var plant = await db.Plants.FindAsync(request.PlantId);
@@ -223,6 +247,104 @@ namespace plant_manager.Endpoints
return Results.NoContent();
});
app.MapPut("/api/care-dismissals/{id:int}", async (int id, UpdateCareDismissalRequest request, ApplicationDbContext db) =>
{
var dismissal = await db.CareDismissals
.Include(item => item.Plant)
.Include(item => item.CareActivity)
.FirstOrDefaultAsync(item => item.Id == id);
if (dismissal is null)
{
return Results.NotFound();
}
var plant = await db.Plants.FindAsync(request.PlantId);
if (plant is null)
{
return Results.BadRequest(new { error = "Plant was not found." });
}
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
dismissal.PlantId = plant.Id;
dismissal.Plant = plant;
dismissal.CareActivityId = activity.Id;
dismissal.CareActivity = activity;
dismissal.DismissedOn = request.DismissedOn;
dismissal.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
await db.SaveChangesAsync();
return Results.Ok(CareHistoryEventDto.FromDismissal(dismissal));
});
app.MapDelete("/api/care-dismissals/{id:int}", async (int id, ApplicationDbContext db) =>
{
var dismissal = await db.CareDismissals.FindAsync(id);
if (dismissal is null)
{
return Results.NotFound();
}
db.CareDismissals.Remove(dismissal);
await db.SaveChangesAsync();
return Results.NoContent();
});
app.MapPut("/api/care-snoozes/{id:int}", async (int id, UpdateCareSnoozeRequest request, ApplicationDbContext db) =>
{
var snooze = await db.CareSnoozes
.Include(item => item.Plant)
.Include(item => item.CareActivity)
.FirstOrDefaultAsync(item => item.Id == id);
if (snooze is null)
{
return Results.NotFound();
}
var plant = await db.Plants.FindAsync(request.PlantId);
if (plant is null)
{
return Results.BadRequest(new { error = "Plant was not found." });
}
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
snooze.PlantId = plant.Id;
snooze.Plant = plant;
snooze.CareActivityId = activity.Id;
snooze.CareActivity = activity;
snooze.SnoozedUntil = request.SnoozedUntil;
snooze.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
await db.SaveChangesAsync();
return Results.Ok(CareHistoryEventDto.FromSnooze(snooze));
});
app.MapDelete("/api/care-snoozes/{id:int}", async (int id, ApplicationDbContext db) =>
{
var snooze = await db.CareSnoozes.FindAsync(id);
if (snooze is null)
{
return Results.NotFound();
}
db.CareSnoozes.Remove(snooze);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
private static async Task<(List<ActionLogResource> Resources, string? Error)> BuildLogResources(
+107 -41
View File
@@ -15,6 +15,8 @@ namespace plant_manager.Endpoints
.Include(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareSnoozes)
.Include(assignment => assignment.Plant)
.ThenInclude(plant => plant.ActionLogs)
.Include(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.CareAction)
@@ -30,13 +32,14 @@ namespace plant_manager.Endpoints
.OrderBy(assignment => assignment.Plant.Nickname)
.ThenBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
.ToListAsync();
var (latestLogLookup, completedLookup) = await GetCareProgress(db);
var (latestLogLookup, completedLookup, snoozeLookup) = await GetCareProgress(db);
var tasks = assignments
.Select(assignment => CareTaskDto.FromAssignment(
assignment,
latestLogLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
completedLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
snoozeLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
today))
.Where(task => task.Status is "due" or "soon")
.ToList();
@@ -94,47 +97,15 @@ namespace plant_manager.Endpoints
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
}
var latestLogs = await db.ActionLogs
.Where(log => log.CareActivityId == activity.Id && plantIds.Contains(log.PlantId))
.GroupBy(log => log.PlantId)
.Select(group => new
{
PlantId = group.Key,
LastPerformedOn = group.Max(log => log.PerformedOn),
CompletedOccurrences = group.Count()
})
.ToListAsync();
var latestLogLookup = latestLogs.ToDictionary(
log => log.PlantId,
log => (DateOnly?)log.LastPerformedOn);
var completedLookup = latestLogs.ToDictionary(
log => log.PlantId,
log => log.CompletedOccurrences);
var dismissals = await db.CareDismissals
.Where(dismissal => dismissal.CareActivityId == activity.Id && plantIds.Contains(dismissal.PlantId))
.GroupBy(dismissal => dismissal.PlantId)
.Select(group => new
{
PlantId = group.Key,
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
DismissedOccurrences = group.Count()
})
.ToListAsync();
foreach (var dismissal in dismissals)
{
latestLogLookup[dismissal.PlantId] = MaxDate(
latestLogLookup.GetValueOrDefault(dismissal.PlantId),
dismissal.LastDismissedOn);
completedLookup[dismissal.PlantId] = completedLookup.GetValueOrDefault(dismissal.PlantId)
+ dismissal.DismissedOccurrences;
}
var (latestLogLookup, completedLookup, snoozeLookup) = await GetCareProgress(db, activity.Id, plantIds);
var duePlantIds = assignments
.Where(assignment =>
PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate(
assignment.PlantCareSchedule,
latestLogLookup.GetValueOrDefault(assignment.PlantId),
completedLookup.GetValueOrDefault(assignment.PlantId)),
latestLogLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
snoozeLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
today) == "due")
.Select(assignment => assignment.PlantId)
.ToHashSet();
@@ -238,14 +209,15 @@ namespace plant_manager.Endpoints
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
}
var (latestLookup, completedLookup) = await GetCareProgress(db, activity.Id, plantIds);
var (latestLookup, completedLookup, snoozeLookup) = await GetCareProgress(db, activity.Id, plantIds);
var duePlantIds = assignments
.Where(assignment =>
PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate(
assignment.PlantCareSchedule,
latestLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
snoozeLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
today) == "due")
.Select(assignment => assignment.PlantId)
.ToHashSet();
@@ -273,26 +245,107 @@ namespace plant_manager.Endpoints
return Results.Ok(new { dismissed = dismissals.Count });
});
app.MapPost("/api/care-tasks/snooze-bulk", async (
SnoozeCareTasksRequest request,
ApplicationDbContext db) =>
{
var plantIds = request.PlantIds
.Where(id => id > 0)
.Distinct()
.ToList();
if (plantIds.Count == 0)
{
return Results.BadRequest(new { error = "At least one plant is required." });
}
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
if (activity is null)
{
return Results.BadRequest(new { error = "Care activity was not found." });
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (request.SnoozedUntil <= today)
{
return Results.BadRequest(new { error = "Snooze date must be after today." });
}
var assignments = await db.PlantCareScheduleAssignments
.Include(assignment => assignment.Plant)
.Include(assignment => assignment.PlantCareSchedule)
.Where(assignment =>
assignment.PlantCareSchedule.CareActivityId == activity.Id
&& plantIds.Contains(assignment.PlantId))
.ToListAsync();
var schedulePlantIds = assignments
.Select(assignment => assignment.PlantId)
.ToHashSet();
if (schedulePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
}
var (latestLookup, completedLookup, snoozeLookup) = await GetCareProgress(db, activity.Id, plantIds);
var duePlantIds = assignments
.Where(assignment =>
PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate(
assignment.PlantCareSchedule,
latestLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
snoozeLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
today) == "due")
.Select(assignment => assignment.PlantId)
.ToHashSet();
if (duePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "Only due care tasks can be snoozed." });
}
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
var snoozes = assignments
.OrderBy(assignment => assignment.Plant.Nickname)
.Select(assignment => new CareSnooze
{
PlantId = assignment.PlantId,
CareActivityId = activity.Id,
SnoozedUntil = request.SnoozedUntil,
CreatedOn = today,
Notes = notes
})
.ToList();
db.CareSnoozes.AddRange(snoozes);
await db.SaveChangesAsync();
return Results.Ok(new { snoozed = snoozes.Count });
});
}
private static async Task<(
Dictionary<(int PlantId, int CareActivityId), DateOnly?> LatestLookup,
Dictionary<(int PlantId, int CareActivityId), int> CompletedLookup)> GetCareProgress(
Dictionary<(int PlantId, int CareActivityId), int> CompletedLookup,
Dictionary<(int PlantId, int CareActivityId), DateOnly?> SnoozeLookup)> GetCareProgress(
ApplicationDbContext db,
int? careActivityId = null,
IReadOnlyList<int>? plantIds = null)
{
var logsQuery = db.ActionLogs.AsQueryable();
var dismissalsQuery = db.CareDismissals.AsQueryable();
var snoozesQuery = db.CareSnoozes.AsQueryable();
if (careActivityId is not null)
{
logsQuery = logsQuery.Where(log => log.CareActivityId == careActivityId);
dismissalsQuery = dismissalsQuery.Where(dismissal => dismissal.CareActivityId == careActivityId);
snoozesQuery = snoozesQuery.Where(snooze => snooze.CareActivityId == careActivityId);
}
if (plantIds is not null)
{
logsQuery = logsQuery.Where(log => plantIds.Contains(log.PlantId));
dismissalsQuery = dismissalsQuery.Where(dismissal => plantIds.Contains(dismissal.PlantId));
snoozesQuery = snoozesQuery.Where(snooze => plantIds.Contains(snooze.PlantId));
}
var latestLogs = await logsQuery
@@ -329,7 +382,20 @@ namespace plant_manager.Endpoints
completedLookup[key] = completedLookup.GetValueOrDefault(key) + dismissal.DismissedOccurrences;
}
return (latestLookup, completedLookup);
var latestSnoozes = await snoozesQuery
.GroupBy(snooze => new { snooze.PlantId, snooze.CareActivityId })
.Select(group => new
{
group.Key.PlantId,
group.Key.CareActivityId,
SnoozedUntil = group.Max(snooze => snooze.SnoozedUntil)
})
.ToListAsync();
var snoozeLookup = latestSnoozes.ToDictionary(
snooze => (snooze.PlantId, snooze.CareActivityId),
snooze => (DateOnly?)snooze.SnoozedUntil);
return (latestLookup, completedLookup, snoozeLookup);
}
private static DateOnly MaxDate(DateOnly? left, DateOnly right) =>
+18
View File
@@ -37,10 +37,16 @@ namespace plant_manager.Endpoints
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareSnoozes)
.Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareDismissals)
.Include(plant => plant.CareSnoozes)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
@@ -80,10 +86,16 @@ namespace plant_manager.Endpoints
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(plant => plant.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareSnoozes)
.Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.CareDismissals)
.Include(plant => plant.CareSnoozes)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
@@ -178,10 +190,16 @@ namespace plant_manager.Endpoints
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareDismissals)
.Include(item => item.CareScheduleAssignments)
.ThenInclude(assignment => assignment.PlantCareSchedule)
.ThenInclude(schedule => schedule.Assignments)
.ThenInclude(assignment => assignment.Plant)
.ThenInclude(plant => plant.CareSnoozes)
.Include(item => item.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(item => item.CareDismissals)
.Include(item => item.CareSnoozes)
.Include(item => item.Flags)
.ThenInclude(flag => flag.Definition)
.Include(item => item.GroupMemberships)