add care history editing and snooze
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+55
-1
@@ -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");
|
||||
+42
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user