improve scheduling
This commit is contained in:
@@ -106,6 +106,14 @@ A per-plant recurring schedule for one care activity. Scheduler is the UI owner
|
||||
| `care_action_id` | `int` | Yes | Snapshot/compatibility foreign key to the primary care action |
|
||||
| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` |
|
||||
| `every_days` | `int` | Yes | Recurrence interval |
|
||||
| `scheduled_for` | `date` | No | Optional specific next due date |
|
||||
| `recurrence_mode` | `string` | Yes | `none`, `daily`, `weekly`, `monthly`, `yearly`, or `custom` |
|
||||
| `repeat_every` | `int` | Yes | Custom repeat interval count |
|
||||
| `repeat_unit` | `string` | Yes | Custom repeat interval unit: `day`, `week`, `month`, or `year` |
|
||||
| `repeat_on_days` | `string` | No | Comma-separated weekday codes for weekly custom repeats |
|
||||
| `ends_mode` | `string` | Yes | `on` or `after` |
|
||||
| `ends_on` | `date` | No | End date when `ends_mode` is `on` |
|
||||
| `ends_after_occurrences` | `int` | No | Occurrence limit when `ends_mode` is `after` |
|
||||
| `is_enabled` | `bool` | Yes | Whether this schedule contributes to care tasks |
|
||||
|
||||
## `ActionLog`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createActionResource,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
getPlantLocations,
|
||||
getPlantTaxa,
|
||||
removePlantFlagAssignment,
|
||||
removePlantCareSchedulesBulk,
|
||||
resolvePlantFlag,
|
||||
savePlantCareSchedulesBulk,
|
||||
updateActionResource,
|
||||
@@ -127,7 +127,6 @@ export function App() {
|
||||
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
|
||||
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
|
||||
const [bulkScheduleForm, setBulkScheduleForm] = useState<BulkScheduleFormState>(emptyBulkScheduleForm);
|
||||
const [plantSearch, setPlantSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -178,22 +177,6 @@ export function App() {
|
||||
() => careTasks.filter((task) => task.status === 'due').length,
|
||||
[careTasks],
|
||||
);
|
||||
const filteredPlants = useMemo(
|
||||
() => filterPlants(plants, plantSearch),
|
||||
[plants, plantSearch],
|
||||
);
|
||||
const filteredPlantIds = useMemo(
|
||||
() => new Set(filteredPlants.map((plant) => plant.id)),
|
||||
[filteredPlants],
|
||||
);
|
||||
const filteredCareTasks = useMemo(
|
||||
() => filterCareTasks(careTasks, filteredPlantIds, plantSearch),
|
||||
[careTasks, filteredPlantIds, plantSearch],
|
||||
);
|
||||
const visibleDueCount = useMemo(
|
||||
() => filteredCareTasks.filter((task) => task.status === 'due').length,
|
||||
[filteredCareTasks],
|
||||
);
|
||||
|
||||
const activePlant = plants.find((plant) => plant.id === editingPlantId);
|
||||
const selectedPlant = plants.find((plant) => plant.id === selectedPlantId);
|
||||
@@ -758,6 +741,24 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBulkSchedule() {
|
||||
if (!bulkScheduleForm.careActivityId || bulkScheduleForm.plantIds.length === 0) {
|
||||
setError('Select an activity and at least one plant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await removePlantCareSchedulesBulk(toBulkSchedulePayload(bulkScheduleForm));
|
||||
setBulkScheduleForm(emptyBulkScheduleForm);
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
setError('Could not remove the care schedule.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFlagDefinition(flag: PlantFlagDefinition) {
|
||||
const confirmed = window.confirm(`Delete ${flag.name}? Flags assigned to plants will be disabled instead.`);
|
||||
if (!confirmed) {
|
||||
@@ -835,11 +836,6 @@ export function App() {
|
||||
}
|
||||
|
||||
async function completeTask(task: CareTask) {
|
||||
const plant = plants.find((item) => item.id === task.plantId);
|
||||
if (plant && !plantMatchesSearch(plant, plantSearch)) {
|
||||
setPlantSearch('');
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await completeCareTasksBulk({
|
||||
@@ -893,16 +889,6 @@ export function App() {
|
||||
<span className="catalog-logo">Plant-Man</span>
|
||||
<span className="catalog-subtitle">Plant Care Supply</span>
|
||||
</div>
|
||||
<label className="search-field">
|
||||
<Search size={20} />
|
||||
<span className="sr-only">Search plants</span>
|
||||
<input
|
||||
value={plantSearch}
|
||||
type="search"
|
||||
placeholder="Search plants"
|
||||
onChange={(event) => setPlantSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="catalog-contact">
|
||||
<strong>{plants.length}</strong>
|
||||
<span>plants tracked</span>
|
||||
@@ -964,7 +950,7 @@ export function App() {
|
||||
</div>
|
||||
|
||||
<div className="nav-group">
|
||||
<h3>Building Blocks</h3>
|
||||
<h3>Catalogs</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'taxa' ? 'page' : undefined}
|
||||
@@ -1023,7 +1009,6 @@ export function App() {
|
||||
isPlantEditorOpen={isPlantEditorOpen}
|
||||
isSaving={isSaving}
|
||||
plants={plants}
|
||||
visiblePlants={filteredPlants}
|
||||
onCancel={cancelEditing}
|
||||
onDelete={(plant) => void removePlant(plant)}
|
||||
onEdit={startEditingPlant}
|
||||
@@ -1040,7 +1025,7 @@ export function App() {
|
||||
plantFlagForm={plantFlagForm}
|
||||
plantLocations={plantLocations}
|
||||
plantTaxa={plantTaxa}
|
||||
plants={filteredPlants}
|
||||
plants={plants}
|
||||
selectedPlant={selectedPlant}
|
||||
selectedPlantId={selectedPlantId}
|
||||
onAssignFlag={() => void assignFlagToSelectedPlant()}
|
||||
@@ -1058,8 +1043,9 @@ export function App() {
|
||||
form={bulkScheduleForm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
plants={filteredPlants}
|
||||
plants={plants}
|
||||
onFieldChange={updateBulkScheduleForm}
|
||||
onRemove={() => void removeBulkSchedule()}
|
||||
onSave={() => void saveBulkSchedule()}
|
||||
/>
|
||||
) : view === 'taxa' ? (
|
||||
@@ -1180,13 +1166,11 @@ export function App() {
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
careTasks={filteredCareTasks}
|
||||
dueCount={plantSearch.trim() ? visibleDueCount : dueCount}
|
||||
careTasks={careTasks}
|
||||
dueCount={dueCount}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isPlantSearchActive={Boolean(plantSearch.trim())}
|
||||
plants={filteredPlants}
|
||||
totalPlantCount={plants.length}
|
||||
plants={plants}
|
||||
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)}
|
||||
onCompleteTask={(task) => void completeTask(task)}
|
||||
onNewPlant={startAddingPlant}
|
||||
@@ -1213,7 +1197,7 @@ function getViewEyebrow(view: View) {
|
||||
case 'locations':
|
||||
case 'resources':
|
||||
case 'flags':
|
||||
return 'Building Blocks';
|
||||
return 'Catalogs';
|
||||
case 'actions':
|
||||
case 'activities':
|
||||
return 'Care';
|
||||
@@ -1247,46 +1231,6 @@ function getViewTitle(view: View) {
|
||||
}
|
||||
}
|
||||
|
||||
function filterPlants(plants: Plant[], search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return plants;
|
||||
}
|
||||
|
||||
return plants.filter((plant) => plantMatchesSearch(plant, normalizedSearch));
|
||||
}
|
||||
|
||||
function filterCareTasks(tasks: CareTask[], visiblePlantIds: Set<number>, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
return tasks.filter((task) => (
|
||||
visiblePlantIds.has(task.plantId)
|
||||
|| task.action.toLowerCase().includes(normalizedSearch)
|
||||
|| task.plantName.toLowerCase().includes(normalizedSearch)
|
||||
|| task.status.toLowerCase().includes(normalizedSearch)
|
||||
));
|
||||
}
|
||||
|
||||
function plantMatchesSearch(plant: Plant, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
plant.nickname,
|
||||
plant.taxon,
|
||||
plant.location,
|
||||
plant.status,
|
||||
plant.nextCare,
|
||||
...plant.flags.map((flag) => flag.name),
|
||||
...plant.careSchedules.map((schedule) => schedule.action),
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function getTodayInputDate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -240,6 +240,13 @@ export async function savePlantCareSchedulesBulk(payload: BulkPlantCareScheduleP
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
|
||||
return request<{ removed: number }>('/api/plant-care-schedules/bulk-remove', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
|
||||
return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,9 +7,7 @@ type HomeViewProps = {
|
||||
dueCount: number;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
isPlantSearchActive: boolean;
|
||||
plants: Plant[];
|
||||
totalPlantCount: number;
|
||||
onCompleteBulkTasks: (tasks: CareTask[]) => void;
|
||||
onCompleteTask: (task: CareTask) => void;
|
||||
onNewPlant: () => void;
|
||||
@@ -21,9 +19,7 @@ export function HomeView({
|
||||
dueCount,
|
||||
error,
|
||||
isLoading,
|
||||
isPlantSearchActive,
|
||||
plants,
|
||||
totalPlantCount,
|
||||
onCompleteBulkTasks,
|
||||
onCompleteTask,
|
||||
onNewPlant,
|
||||
@@ -51,48 +47,56 @@ export function HomeView({
|
||||
|
||||
<div className="task-list">
|
||||
{!isLoading && careTasks.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
{isPlantSearchActive ? 'No care tasks match the search.' : 'No care tasks yet.'}
|
||||
</p>
|
||||
<p className="empty-state">No care tasks yet.</p>
|
||||
) : null}
|
||||
|
||||
{dueTaskGroups.map((group) => (
|
||||
<article className="task-row task-row-group" key={group.careActivityId}>
|
||||
<span className="status-dot due" />
|
||||
<div>
|
||||
<h3>{group.action}</h3>
|
||||
<p>
|
||||
{group.tasks.length} due - {formatPlantNames(group.tasks)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log all due
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{dueTaskGroups.length > 0 ? (
|
||||
<>
|
||||
<p className="task-list-label">Bulk actions</p>
|
||||
{dueTaskGroups.map((group) => (
|
||||
<article className="task-row task-row-group" key={group.careActivityId}>
|
||||
<span className="status-dot due" />
|
||||
<div>
|
||||
<h3>{group.action}</h3>
|
||||
<p>
|
||||
{group.tasks.length} due - {formatPlantNames(group.tasks)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log all due
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{careTasks.map((task) => (
|
||||
<article className="task-row" key={task.id}>
|
||||
<span className={`status-dot ${task.status}`} />
|
||||
<div>
|
||||
<h3>{task.action}</h3>
|
||||
<p>{task.plantName} - {task.due}</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{careTasks.length > 0 ? (
|
||||
<>
|
||||
<p className="task-list-label">Individual tasks</p>
|
||||
{careTasks.map((task) => (
|
||||
<article className="task-row" key={task.id}>
|
||||
<span className={`status-dot ${task.status}`} />
|
||||
<div>
|
||||
<h3>{task.action}</h3>
|
||||
<p>{task.plantName} - {task.due}</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -105,14 +109,10 @@ export function HomeView({
|
||||
</div>
|
||||
|
||||
<div className="plant-grid">
|
||||
{!isLoading && totalPlantCount === 0 ? (
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && totalPlantCount > 0 && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants match the search.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
|
||||
))}
|
||||
|
||||
@@ -51,6 +51,8 @@ export function PlantManagementView({
|
||||
const enabledFlags = plantFlagDefinitions.filter((flag) => flag.isEnabled);
|
||||
const enabledLocations = plantLocations.filter((location) =>
|
||||
location.isEnabled || location.id === selectedPlant?.locationId);
|
||||
const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedPlant?.taxonId);
|
||||
const selectedLocation = plantLocations.find((location) => location.id === selectedPlant?.locationId);
|
||||
const activeFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? [];
|
||||
const resolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? [];
|
||||
|
||||
@@ -62,7 +64,7 @@ export function PlantManagementView({
|
||||
<h2 id="plant-management-summary-heading">
|
||||
{isLoading ? 'Loading plants' : 'Plant Management'}
|
||||
</h2>
|
||||
<p>{error ?? 'Attach building blocks to plant objects without changing the building block libraries.'}</p>
|
||||
<p>{error ?? 'Attach catalog records to plant objects without changing the catalogs.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -94,10 +96,10 @@ export function PlantManagementView({
|
||||
|
||||
{selectedPlant ? (
|
||||
<div className="plant-detail-grid">
|
||||
<section className="detail-section" aria-labelledby="plant-management-identity">
|
||||
<section className="detail-section" aria-labelledby="plant-management-taxa">
|
||||
<div className="detail-section-heading">
|
||||
<Tags size={17} />
|
||||
<h3 id="plant-management-identity">Identity</h3>
|
||||
<h3 id="plant-management-taxa">Plant Taxa</h3>
|
||||
</div>
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
@@ -116,12 +118,26 @@ export function PlantManagementView({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{selectedTaxon ? (
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Common name</span>
|
||||
<strong>{selectedTaxon.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Botanical name</span>
|
||||
<strong>{formatTaxon(selectedTaxon)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-state">No taxon assigned.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section" aria-labelledby="plant-management-placement">
|
||||
<div className="detail-section-heading">
|
||||
<MapPin size={17} />
|
||||
<h3 id="plant-management-placement">Placement</h3>
|
||||
<h3 id="plant-management-placement">Location</h3>
|
||||
</div>
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
@@ -140,6 +156,26 @@ export function PlantManagementView({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{selectedLocation ? (
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Location</span>
|
||||
<strong>{selectedLocation.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{selectedLocation.isEnabled ? 'Enabled' : 'Disabled'}</strong>
|
||||
</div>
|
||||
{selectedLocation.notes ? (
|
||||
<div className="meta-wide">
|
||||
<span>Notes</span>
|
||||
<strong>{selectedLocation.notes}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-state">No location assigned.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section detail-section-wide" aria-labelledby="plant-management-flags">
|
||||
@@ -147,6 +183,16 @@ export function PlantManagementView({
|
||||
<Check size={17} />
|
||||
<h3 id="plant-management-flags">Flags</h3>
|
||||
</div>
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Active</span>
|
||||
<strong>{activeFlags.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Resolved</span>
|
||||
<strong>{resolvedFlags.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flag-assignment-form">
|
||||
<label>
|
||||
@@ -192,47 +238,53 @@ export function PlantManagementView({
|
||||
{activeFlags.length === 0 ? (
|
||||
<p className="empty-state">No active flags.</p>
|
||||
) : (
|
||||
<div className="detail-list">
|
||||
{activeFlags.map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>
|
||||
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
</h4>
|
||||
<p>
|
||||
Started {formatDate(flag.startedOn)}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
<>
|
||||
<p className="list-label">Active flags</p>
|
||||
<div className="detail-list">
|
||||
{activeFlags.map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>
|
||||
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
</h4>
|
||||
<p>
|
||||
Started {formatDate(flag.startedOn)}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
|
||||
Resolve
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
|
||||
Resolve
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resolvedFlags.length > 0 ? (
|
||||
<div className="detail-list resolved-flags">
|
||||
{resolvedFlags.slice(0, 4).map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>{flag.name}</h4>
|
||||
<p>
|
||||
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
<>
|
||||
<p className="list-label">Recently resolved</p>
|
||||
<div className="detail-list resolved-flags">
|
||||
{resolvedFlags.slice(0, 4).map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>{flag.name}</h4>
|
||||
<p>
|
||||
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ type PlantsViewProps = {
|
||||
isPlantEditorOpen: boolean;
|
||||
isSaving: boolean;
|
||||
plants: Plant[];
|
||||
visiblePlants: Plant[];
|
||||
onCancel: () => void;
|
||||
onDelete: (plant: Plant) => void;
|
||||
onEdit: (plant: Plant) => void;
|
||||
@@ -27,7 +26,6 @@ export function PlantsView({
|
||||
isPlantEditorOpen,
|
||||
isSaving,
|
||||
plants,
|
||||
visiblePlants,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
@@ -95,11 +93,7 @@ export function PlantsView({
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && plants.length > 0 && visiblePlants.length === 0 ? (
|
||||
<p className="empty-state">No plants match the search.</p>
|
||||
) : null}
|
||||
|
||||
{visiblePlants.map((plant) => (
|
||||
{plants.map((plant) => (
|
||||
<article className="plant-row" key={plant.id}>
|
||||
<div>
|
||||
<h3>{plant.nickname}</h3>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Save } from 'lucide-react';
|
||||
import { CalendarClock, Save, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CareActivity, Plant } from '../domain';
|
||||
import type { BulkScheduleFormState } from '../form-state';
|
||||
|
||||
@@ -10,9 +11,29 @@ type SchedulesViewProps = {
|
||||
isSaving: boolean;
|
||||
plants: Plant[];
|
||||
onFieldChange: (field: keyof BulkScheduleFormState, value: string | boolean | string[]) => void;
|
||||
onRemove: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
const recurrenceOptions = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
{ value: 'daily', label: 'Every day' },
|
||||
{ value: 'weekly', label: 'Every week' },
|
||||
{ value: 'monthly', label: 'Every month' },
|
||||
{ value: 'yearly', label: 'Every year' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
const weekdayOptions = [
|
||||
{ value: 'SU', label: 'S' },
|
||||
{ value: 'MO', label: 'M' },
|
||||
{ value: 'TU', label: 'T' },
|
||||
{ value: 'WE', label: 'W' },
|
||||
{ value: 'TH', label: 'T' },
|
||||
{ value: 'FR', label: 'F' },
|
||||
{ value: 'SA', label: 'S' },
|
||||
];
|
||||
|
||||
export function SchedulesView({
|
||||
activities,
|
||||
error,
|
||||
@@ -21,11 +42,23 @@ export function SchedulesView({
|
||||
isSaving,
|
||||
plants,
|
||||
onFieldChange,
|
||||
onRemove,
|
||||
onSave,
|
||||
}: SchedulesViewProps) {
|
||||
const [plantQuery, setPlantQuery] = useState('');
|
||||
const enabledActivities = activities.filter((activity) => activity.isEnabled);
|
||||
const visiblePlants = useMemo(
|
||||
() => filterPlants(plants, plantQuery),
|
||||
[plants, plantQuery],
|
||||
);
|
||||
const selectedPlantIds = new Set(form.plantIds);
|
||||
const allVisibleSelected = plants.length > 0 && plants.every((plant) => selectedPlantIds.has(String(plant.id)));
|
||||
const selectedPlants = plants.filter((plant) => selectedPlantIds.has(String(plant.id)));
|
||||
const selectedActivity = activities.find((activity) => String(activity.id) === form.careActivityId);
|
||||
const preview = selectedActivity ? formatSchedulePreview(selectedActivity.name, form) : '';
|
||||
const allVisibleSelected = visiblePlants.length > 0 && visiblePlants.every((plant) => selectedPlantIds.has(String(plant.id)));
|
||||
const selectedPlantsWithActivity = selectedActivity
|
||||
? selectedPlants.filter((plant) => plant.careSchedules.some((schedule) => schedule.careActivityId === selectedActivity.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,17 +66,21 @@ export function SchedulesView({
|
||||
<div>
|
||||
<p className="eyebrow">Care schedules</p>
|
||||
<h2 id="schedules-summary-heading">
|
||||
{isLoading ? 'Loading schedules' : 'Bulk schedule assignment'}
|
||||
{isLoading ? 'Loading schedules' : 'Scheduler'}
|
||||
</h2>
|
||||
<p>{error ?? 'Apply one care interval to several plants at once.'}</p>
|
||||
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="editor-panel" aria-labelledby="bulk-schedule-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Bulk edit</p>
|
||||
<h2 id="bulk-schedule-heading">Apply schedule</h2>
|
||||
<p className="eyebrow">Schedule rule</p>
|
||||
<h2 id="bulk-schedule-heading">Apply activity interval</h2>
|
||||
</div>
|
||||
<div className="schedule-count">
|
||||
<CalendarClock size={16} />
|
||||
<span>{selectedPlants.length} selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +88,7 @@ export function SchedulesView({
|
||||
<label>
|
||||
Activity
|
||||
<select
|
||||
disabled={isSaving}
|
||||
value={form.careActivityId}
|
||||
onChange={(event) => onFieldChange('careActivityId', event.target.value)}
|
||||
>
|
||||
@@ -63,18 +101,18 @@ export function SchedulesView({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Every days
|
||||
Start date
|
||||
<input
|
||||
min="1"
|
||||
max="365"
|
||||
type="number"
|
||||
value={form.everyDays}
|
||||
onChange={(event) => onFieldChange('everyDays', event.target.value)}
|
||||
disabled={isSaving}
|
||||
type="date"
|
||||
value={form.scheduledFor}
|
||||
onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
checked={form.isEnabled}
|
||||
disabled={isSaving}
|
||||
type="checkbox"
|
||||
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
|
||||
/>
|
||||
@@ -82,31 +120,181 @@ export function SchedulesView({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="section" aria-labelledby="bulk-schedule-plants-heading">
|
||||
<fieldset className="schedule-options">
|
||||
<legend>Repeat</legend>
|
||||
{recurrenceOptions.map((option) => (
|
||||
<label className="check-option" key={option.value}>
|
||||
<input
|
||||
checked={form.recurrenceMode === option.value}
|
||||
disabled={isSaving}
|
||||
type="radio"
|
||||
name="recurrenceMode"
|
||||
value={option.value}
|
||||
onChange={(event) => onFieldChange('recurrenceMode', event.target.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{form.recurrenceMode === 'custom' ? (
|
||||
<div className="custom-schedule-options">
|
||||
<div className="repeat-every-row">
|
||||
<span>Repeats every</span>
|
||||
<input
|
||||
disabled={isSaving}
|
||||
min="1"
|
||||
max="365"
|
||||
type="number"
|
||||
value={form.repeatEvery}
|
||||
onChange={(event) => onFieldChange('repeatEvery', event.target.value)}
|
||||
/>
|
||||
<select
|
||||
disabled={isSaving}
|
||||
value={form.repeatUnit}
|
||||
onChange={(event) => onFieldChange('repeatUnit', event.target.value)}
|
||||
>
|
||||
<option value="day">day</option>
|
||||
<option value="week">week</option>
|
||||
<option value="month">month</option>
|
||||
<option value="year">year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.repeatUnit === 'week' ? (
|
||||
<fieldset className="weekday-options">
|
||||
<legend>Repeats on</legend>
|
||||
{weekdayOptions.map((day) => (
|
||||
<label key={day.value}>
|
||||
<input
|
||||
checked={form.repeatOnDays.includes(day.value)}
|
||||
disabled={isSaving}
|
||||
type="checkbox"
|
||||
onChange={(event) => {
|
||||
const nextDays = event.target.checked
|
||||
? [...form.repeatOnDays, day.value]
|
||||
: form.repeatOnDays.filter((value) => value !== day.value);
|
||||
onFieldChange('repeatOnDays', nextDays);
|
||||
}}
|
||||
/>
|
||||
<span>{day.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{form.recurrenceMode !== 'none' ? (
|
||||
<fieldset className="schedule-end-options">
|
||||
<legend>Ends</legend>
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={form.endsMode === 'on'}
|
||||
disabled={isSaving}
|
||||
type="radio"
|
||||
name="endsMode"
|
||||
value="on"
|
||||
onChange={(event) => onFieldChange('endsMode', event.target.value)}
|
||||
/>
|
||||
On date
|
||||
</label>
|
||||
{form.endsMode === 'on' ? (
|
||||
<input
|
||||
aria-label="End date"
|
||||
disabled={isSaving}
|
||||
type="date"
|
||||
value={form.endsOn}
|
||||
onChange={(event) => onFieldChange('endsOn', event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={form.endsMode === 'after'}
|
||||
disabled={isSaving}
|
||||
type="radio"
|
||||
name="endsMode"
|
||||
value="after"
|
||||
onChange={(event) => onFieldChange('endsMode', event.target.value)}
|
||||
/>
|
||||
After
|
||||
</label>
|
||||
{form.endsMode === 'after' ? (
|
||||
<input
|
||||
aria-label="Occurrences"
|
||||
disabled={isSaving}
|
||||
min="1"
|
||||
max="999"
|
||||
type="number"
|
||||
value={form.endsAfterOccurrences}
|
||||
onChange={(event) => onFieldChange('endsAfterOccurrences', event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{selectedActivity ? (
|
||||
<p className="schedule-hint">
|
||||
{selectedPlantsWithActivity.length} of {selectedPlants.length} selected plants already have {selectedActivity.name}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<div className="schedule-preview" aria-label="Schedule preview">
|
||||
<span>Preview</span>
|
||||
<strong>{preview}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="section schedule-targets" aria-labelledby="bulk-schedule-plants-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="bulk-schedule-plants-heading">Plants</h2>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() => onFieldChange(
|
||||
'plantIds',
|
||||
allVisibleSelected ? [] : plants.map((plant) => String(plant.id)),
|
||||
)}
|
||||
>
|
||||
{allVisibleSelected ? 'Clear all' : 'Select all'}
|
||||
</button>
|
||||
<div>
|
||||
<p className="eyebrow">Targets</p>
|
||||
<h2 id="bulk-schedule-plants-heading">Plants</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => onFieldChange(
|
||||
'plantIds',
|
||||
allVisibleSelected
|
||||
? form.plantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
|
||||
: [...new Set([...form.plantIds, ...visiblePlants.map((plant) => String(plant.id))])],
|
||||
)}
|
||||
>
|
||||
{allVisibleSelected ? 'Clear visible' : 'Select visible'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
<label className="compact-search">
|
||||
<span className="sr-only">Search target plants</span>
|
||||
<input
|
||||
disabled={isSaving}
|
||||
type="search"
|
||||
value={plantQuery}
|
||||
placeholder="Search plants"
|
||||
onChange={(event) => setPlantQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="plant-list compact-plant-list">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants available.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
{!isLoading && plants.length > 0 && visiblePlants.length === 0 ? (
|
||||
<p className="empty-state">No plants match that search.</p>
|
||||
) : null}
|
||||
|
||||
{visiblePlants.map((plant) => (
|
||||
<label className="plant-row check-row" key={plant.id}>
|
||||
<span>
|
||||
<input
|
||||
checked={selectedPlantIds.has(String(plant.id))}
|
||||
disabled={isSaving}
|
||||
type="checkbox"
|
||||
onChange={(event) => {
|
||||
const plantId = String(plant.id);
|
||||
@@ -116,10 +304,8 @@ export function SchedulesView({
|
||||
onFieldChange('plantIds', nextPlantIds);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{plant.nickname}</strong>
|
||||
<small>{plant.taxon} - {plant.location}</small>
|
||||
</span>
|
||||
<strong>{plant.nickname}</strong>
|
||||
<small>{formatPlantScheduleSummary(plant, selectedActivity)}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -131,8 +317,129 @@ export function SchedulesView({
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : `Apply to ${form.plantIds.length} plants`}
|
||||
</button>
|
||||
<button className="text-button danger" type="button" disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId} onClick={onRemove}>
|
||||
<Trash2 size={16} />
|
||||
Remove from {form.plantIds.length} plants
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" aria-labelledby="current-schedules-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Current state</p>
|
||||
<h2 id="current-schedules-heading">Selected Plant Schedules</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-list">
|
||||
{!isLoading && selectedPlants.length === 0 ? (
|
||||
<p className="empty-state">Select one or more plants to review their current schedules.</p>
|
||||
) : null}
|
||||
|
||||
{selectedPlants.map((plant) => (
|
||||
<article className="detail-row schedule-detail-row" key={plant.id}>
|
||||
<div>
|
||||
<h4>{plant.nickname}</h4>
|
||||
<p>{plant.taxon} - {plant.location}</p>
|
||||
{plant.careSchedules.length === 0 ? (
|
||||
<p>No schedules.</p>
|
||||
) : (
|
||||
<div className="schedule-chip-list">
|
||||
{plant.careSchedules.map((schedule) => (
|
||||
<span className={`status-pill ${schedule.status}`} key={schedule.id}>
|
||||
{schedule.action} / {formatRecurrence(schedule)} / {schedule.isEnabled ? schedule.nextCare : 'Disabled'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlantScheduleSummary(plant: Plant, selectedActivity?: CareActivity) {
|
||||
if (!selectedActivity) {
|
||||
return `${plant.careSchedules.length} schedules - next care ${plant.nextCare}`;
|
||||
}
|
||||
|
||||
const schedule = plant.careSchedules.find((item) => item.careActivityId === selectedActivity.id);
|
||||
if (!schedule) {
|
||||
return `No ${selectedActivity.name} schedule`;
|
||||
}
|
||||
|
||||
return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.isEnabled ? schedule.nextCare : 'disabled'}`;
|
||||
}
|
||||
|
||||
function filterPlants(plants: Plant[], query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return plants;
|
||||
}
|
||||
|
||||
return plants.filter((plant) => [
|
||||
plant.nickname,
|
||||
plant.taxon,
|
||||
plant.location,
|
||||
plant.nextCare,
|
||||
plant.status,
|
||||
...plant.careSchedules.map((schedule) => schedule.action),
|
||||
].some((value) => value.toLowerCase().includes(normalizedQuery)));
|
||||
}
|
||||
|
||||
function formatSchedulePreview(activityName: string, form: BulkScheduleFormState) {
|
||||
return `${activityName}: ${formatRecurrence({
|
||||
recurrenceMode: form.recurrenceMode,
|
||||
repeatEvery: Number(form.repeatEvery),
|
||||
repeatUnit: form.repeatUnit,
|
||||
repeatOnDays: form.repeatOnDays.length > 0 ? form.repeatOnDays.join(',') : null,
|
||||
scheduledFor: form.scheduledFor || null,
|
||||
endsMode: form.endsMode,
|
||||
endsOn: form.endsMode === 'on' ? form.endsOn || null : null,
|
||||
endsAfterOccurrences: form.endsMode === 'after' ? Number(form.endsAfterOccurrences) : null,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function formatRecurrence(schedule: {
|
||||
recurrenceMode: string;
|
||||
repeatEvery: number;
|
||||
repeatUnit: string;
|
||||
repeatOnDays: string | null;
|
||||
scheduledFor: string | null;
|
||||
endsMode: string;
|
||||
endsOn: string | null;
|
||||
endsAfterOccurrences: number | null;
|
||||
}) {
|
||||
const start = schedule.scheduledFor ? ` from ${formatCalendarDate(schedule.scheduledFor)}` : '';
|
||||
const ending = schedule.endsMode === 'on' && schedule.endsOn
|
||||
? ` until ${formatCalendarDate(schedule.endsOn)}`
|
||||
: schedule.endsMode === 'after' && schedule.endsAfterOccurrences
|
||||
? ` for ${schedule.endsAfterOccurrences}x`
|
||||
: '';
|
||||
|
||||
if (schedule.recurrenceMode === 'none') {
|
||||
return schedule.scheduledFor ? `does not repeat, ${formatCalendarDate(schedule.scheduledFor)}` : 'does not repeat';
|
||||
}
|
||||
|
||||
if (schedule.recurrenceMode !== 'custom') {
|
||||
return `${recurrenceOptions.find((option) => option.value === schedule.recurrenceMode)?.label.toLowerCase() ?? 'repeats'}${start}${ending}`;
|
||||
}
|
||||
|
||||
const days = schedule.repeatOnDays
|
||||
? ` on ${schedule.repeatOnDays.split(',').join(' ')}`
|
||||
: '';
|
||||
return `every ${schedule.repeatEvery} ${schedule.repeatUnit}${schedule.repeatEvery === 1 ? '' : 's'}${days}${start}${ending}`;
|
||||
}
|
||||
|
||||
function formatCalendarDate(date: string) {
|
||||
const [year, month, day] = date.split('-');
|
||||
if (!year || !month || !day) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ export type PlantCareSchedule = {
|
||||
careActionId: number;
|
||||
action: string;
|
||||
everyDays: number;
|
||||
scheduledFor: string | null;
|
||||
recurrenceMode: ScheduleRecurrenceMode;
|
||||
repeatEvery: number;
|
||||
repeatUnit: ScheduleRepeatUnit;
|
||||
repeatOnDays: string | null;
|
||||
endsMode: ScheduleEndsMode;
|
||||
endsOn: string | null;
|
||||
endsAfterOccurrences: number | null;
|
||||
lastPerformedOn: string | null;
|
||||
lastPerformed: string;
|
||||
nextCare: string;
|
||||
@@ -26,6 +34,10 @@ export type PlantCareSchedule = {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
|
||||
export type ScheduleEndsMode = 'on' | 'after';
|
||||
|
||||
export type PlantTaxon = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -112,6 +124,14 @@ export type PlantPayload = {
|
||||
export type PlantCareSchedulePayload = {
|
||||
careActivityId: number;
|
||||
everyDays: number;
|
||||
scheduledFor: string | null;
|
||||
recurrenceMode: ScheduleRecurrenceMode;
|
||||
repeatEvery: number;
|
||||
repeatUnit: ScheduleRepeatUnit;
|
||||
repeatOnDays: string | null;
|
||||
endsMode: ScheduleEndsMode;
|
||||
endsOn: string | null;
|
||||
endsAfterOccurrences: number | null;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -119,6 +139,14 @@ export type BulkPlantCareSchedulePayload = {
|
||||
plantIds: number[];
|
||||
careActivityId: number;
|
||||
everyDays: number;
|
||||
scheduledFor: string | null;
|
||||
recurrenceMode: ScheduleRecurrenceMode;
|
||||
repeatEvery: number;
|
||||
repeatUnit: ScheduleRepeatUnit;
|
||||
repeatOnDays: string | null;
|
||||
endsMode: ScheduleEndsMode;
|
||||
endsOn: string | null;
|
||||
endsAfterOccurrences: number | null;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
PlantFlagDefinitionPayload,
|
||||
PlantLocation,
|
||||
PlantLocationPayload,
|
||||
ScheduleEndsMode,
|
||||
ScheduleRecurrenceMode,
|
||||
ScheduleRepeatUnit,
|
||||
PlantTaxon,
|
||||
PlantTaxonPayload,
|
||||
} from './domain';
|
||||
@@ -83,6 +86,14 @@ export const emptyPlantFlagForm = {
|
||||
export const emptyBulkScheduleForm = {
|
||||
careActivityId: '',
|
||||
everyDays: '7',
|
||||
scheduledFor: '',
|
||||
recurrenceMode: 'weekly',
|
||||
repeatEvery: '1',
|
||||
repeatUnit: 'week',
|
||||
repeatOnDays: [] as string[],
|
||||
endsMode: 'after',
|
||||
endsOn: '',
|
||||
endsAfterOccurrences: '12',
|
||||
isEnabled: true,
|
||||
plantIds: [] as string[],
|
||||
};
|
||||
@@ -248,6 +259,18 @@ export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCar
|
||||
plantIds: form.plantIds.map((id) => Number(id)),
|
||||
careActivityId: Number(form.careActivityId),
|
||||
everyDays: Number(form.everyDays),
|
||||
scheduledFor: form.scheduledFor || null,
|
||||
recurrenceMode: form.recurrenceMode as ScheduleRecurrenceMode,
|
||||
repeatEvery: Number(form.repeatEvery),
|
||||
repeatUnit: form.repeatUnit as ScheduleRepeatUnit,
|
||||
repeatOnDays: form.repeatOnDays.length > 0 ? form.repeatOnDays.join(',') : null,
|
||||
endsMode: (form.recurrenceMode === 'none' ? 'after' : form.endsMode) as ScheduleEndsMode,
|
||||
endsOn: form.endsMode === 'on' ? form.endsOn || null : null,
|
||||
endsAfterOccurrences: form.recurrenceMode === 'none'
|
||||
? 1
|
||||
: form.endsMode === 'after'
|
||||
? Number(form.endsAfterOccurrences)
|
||||
: null,
|
||||
isEnabled: form.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,35 +154,6 @@ p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #8d8d8d;
|
||||
background: #393939;
|
||||
color: #78a9ff;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: #c6c6c6;
|
||||
}
|
||||
|
||||
.catalog-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 228px minmax(0, 1fr);
|
||||
@@ -491,10 +462,24 @@ p {
|
||||
}
|
||||
|
||||
.task-row-group {
|
||||
border-top: 1px solid var(--rule);
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.task-list-label {
|
||||
margin: 0;
|
||||
padding: 7px 8px 5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f4f4f4;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-list-label:not(:first-child) {
|
||||
border-top: 2px solid var(--rule);
|
||||
}
|
||||
|
||||
.plant-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -511,6 +496,8 @@ p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
@@ -520,10 +507,18 @@ p {
|
||||
}
|
||||
|
||||
.check-row small {
|
||||
display: block;
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #444;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-row small::before {
|
||||
content: "- ";
|
||||
}
|
||||
|
||||
.plant-card {
|
||||
@@ -844,8 +839,153 @@ dd {
|
||||
padding: 8px 0 8px 12px;
|
||||
}
|
||||
|
||||
.schedule-interval {
|
||||
grid-template-columns: minmax(90px, 120px);
|
||||
.schedule-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.schedule-hint {
|
||||
margin: 8px 0 0;
|
||||
color: #444;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border-left: 3px solid var(--link);
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.schedule-preview span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.schedule-preview strong {
|
||||
color: #161616;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.schedule-options,
|
||||
.custom-schedule-options,
|
||||
.weekday-options,
|
||||
.schedule-end-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.schedule-options legend,
|
||||
.weekday-options legend,
|
||||
.schedule-end-options legend {
|
||||
padding: 0 4px;
|
||||
color: #525252;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.custom-schedule-options {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.repeat-every-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(70px, 90px) minmax(120px, 150px);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repeat-every-row input,
|
||||
.repeat-every-row select,
|
||||
.schedule-end-options input[type='date'],
|
||||
.schedule-end-options input[type='number'] {
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #8d8d8d;
|
||||
background: var(--field);
|
||||
}
|
||||
|
||||
.weekday-options label {
|
||||
display: inline-grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
background: #f4f4f4;
|
||||
color: #161616;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.weekday-options input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.weekday-options label:has(input:checked) {
|
||||
border-color: var(--link);
|
||||
background: #e8f0ff;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.schedule-targets {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.compact-search {
|
||||
display: grid;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.compact-search input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #8d8d8d;
|
||||
border-radius: 0;
|
||||
background: var(--field);
|
||||
color: #161616;
|
||||
}
|
||||
|
||||
.compact-plant-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.compact-plant-list .plant-row {
|
||||
min-height: 34px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.schedule-detail-row {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.schedule-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.plant-detail-meta,
|
||||
@@ -884,6 +1024,16 @@ dd {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.compact-meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.compact-meta .meta-wide {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.plant-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
@@ -947,6 +1097,7 @@ dd {
|
||||
}
|
||||
|
||||
.flag-assignment-form {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f4f4f4;
|
||||
@@ -956,8 +1107,15 @@ dd {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.list-label {
|
||||
margin: 10px 0 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.resolved-flags {
|
||||
margin-top: 8px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
|
||||
+133
-5
@@ -17,12 +17,28 @@ namespace plant_manager
|
||||
public record SavePlantCareScheduleRequest(
|
||||
int CareActivityId,
|
||||
int? EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string? RecurrenceMode,
|
||||
int? RepeatEvery,
|
||||
string? RepeatUnit,
|
||||
string? RepeatOnDays,
|
||||
string? EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences,
|
||||
bool IsEnabled);
|
||||
|
||||
public record BulkSavePlantCareScheduleRequest(
|
||||
IReadOnlyList<int> PlantIds,
|
||||
int CareActivityId,
|
||||
int? EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string? RecurrenceMode,
|
||||
int? RepeatEvery,
|
||||
string? RepeatUnit,
|
||||
string? RepeatOnDays,
|
||||
string? EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences,
|
||||
bool IsEnabled);
|
||||
|
||||
public record SavePlantTaxonRequest(
|
||||
@@ -242,7 +258,14 @@ namespace plant_manager
|
||||
.ToList();
|
||||
var nextCare = schedules
|
||||
.Where(schedule => schedule.IsEnabled)
|
||||
.Select(schedule => PlantCareFormatter.GetNextCareDate(schedule.LastPerformedOn, schedule.EveryDays))
|
||||
.Select(schedule =>
|
||||
{
|
||||
var source = plant.CareSchedules.First(item => item.Id == schedule.Id);
|
||||
return PlantCareFormatter.GetNextCareDate(
|
||||
source,
|
||||
schedule.LastPerformedOn,
|
||||
plant.ActionLogs.Count(log => log.CareActivityId == source.CareActivityId));
|
||||
})
|
||||
.Where(date => date is not null)
|
||||
.OrderBy(date => date)
|
||||
.FirstOrDefault();
|
||||
@@ -278,6 +301,14 @@ namespace plant_manager
|
||||
int CareActionId,
|
||||
string Action,
|
||||
int EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string RecurrenceMode,
|
||||
int RepeatEvery,
|
||||
string RepeatUnit,
|
||||
string? RepeatOnDays,
|
||||
string EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences,
|
||||
DateOnly? LastPerformedOn,
|
||||
string LastPerformed,
|
||||
string NextCare,
|
||||
@@ -289,7 +320,7 @@ namespace plant_manager
|
||||
DateOnly? lastPerformedOn,
|
||||
DateOnly today)
|
||||
{
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays);
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(schedule));
|
||||
|
||||
return new PlantCareScheduleDto(
|
||||
schedule.Id,
|
||||
@@ -297,12 +328,23 @@ namespace plant_manager
|
||||
schedule.CareActionId,
|
||||
schedule.CareActivity.Name,
|
||||
schedule.EveryDays,
|
||||
schedule.ScheduledFor,
|
||||
schedule.RecurrenceMode,
|
||||
schedule.RepeatEvery,
|
||||
schedule.RepeatUnit,
|
||||
schedule.RepeatOnDays,
|
||||
schedule.EndsMode,
|
||||
schedule.EndsOn,
|
||||
schedule.EndsAfterOccurrences,
|
||||
lastPerformedOn,
|
||||
PlantCareFormatter.FormatRelativeDate(lastPerformedOn, today, "Never"),
|
||||
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
|
||||
PlantCareFormatter.GetStatus(nextCare, today),
|
||||
schedule.IsEnabled);
|
||||
}
|
||||
|
||||
private static int GetCompletedOccurrences(PlantCareSchedule schedule) =>
|
||||
schedule.Plant.ActionLogs.Count(log => log.CareActivityId == schedule.CareActivityId);
|
||||
}
|
||||
|
||||
internal static class CareActivityExtensions
|
||||
@@ -333,9 +375,10 @@ namespace plant_manager
|
||||
public static CareTaskDto FromSchedule(
|
||||
PlantCareSchedule schedule,
|
||||
DateOnly? lastPerformedOn,
|
||||
int completedOccurrences,
|
||||
DateOnly today)
|
||||
{
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays);
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
|
||||
|
||||
return new CareTaskDto(
|
||||
schedule.Id,
|
||||
@@ -427,8 +470,93 @@ namespace plant_manager
|
||||
|
||||
internal static class PlantCareFormatter
|
||||
{
|
||||
public static DateOnly? GetNextCareDate(DateOnly? lastPerformedOn, int everyDays) =>
|
||||
lastPerformedOn?.AddDays(everyDays);
|
||||
public static DateOnly? GetNextCareDate(
|
||||
PlantCareSchedule schedule,
|
||||
DateOnly? lastPerformedOn,
|
||||
int completedOccurrences = 0)
|
||||
{
|
||||
if (schedule.EndsMode == "after" && schedule.EndsAfterOccurrences is not null && completedOccurrences >= schedule.EndsAfterOccurrences)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DateOnly? nextCare;
|
||||
if (schedule.RecurrenceMode == "none")
|
||||
{
|
||||
nextCare = schedule.ScheduledFor is not null && (lastPerformedOn is null || schedule.ScheduledFor > lastPerformedOn)
|
||||
? schedule.ScheduledFor
|
||||
: null;
|
||||
}
|
||||
else if (lastPerformedOn is null)
|
||||
{
|
||||
nextCare = schedule.ScheduledFor;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextCare = AddInterval(lastPerformedOn.Value, schedule);
|
||||
}
|
||||
|
||||
if (nextCare is not null && schedule.EndsMode == "on" && schedule.EndsOn is not null && nextCare > schedule.EndsOn)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextCare;
|
||||
}
|
||||
|
||||
private static DateOnly AddInterval(DateOnly date, PlantCareSchedule schedule)
|
||||
{
|
||||
var repeatEvery = Math.Clamp(schedule.RepeatEvery, 1, 365);
|
||||
var unit = schedule.RecurrenceMode == "custom" ? schedule.RepeatUnit : schedule.RecurrenceMode;
|
||||
|
||||
return unit switch
|
||||
{
|
||||
"day" or "daily" => date.AddDays(repeatEvery),
|
||||
"week" when !string.IsNullOrWhiteSpace(schedule.RepeatOnDays) => GetNextSelectedWeekday(date, schedule.RepeatOnDays, repeatEvery),
|
||||
"week" or "weekly" => date.AddDays(repeatEvery * 7),
|
||||
"month" or "monthly" => date.AddMonths(repeatEvery),
|
||||
"year" or "yearly" => date.AddYears(repeatEvery),
|
||||
_ => date.AddDays(Math.Clamp(schedule.EveryDays, 1, 365))
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly GetNextSelectedWeekday(DateOnly date, string repeatOnDays, int repeatEvery)
|
||||
{
|
||||
var selectedDays = repeatOnDays
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(ParseDayOfWeek)
|
||||
.OfType<DayOfWeek>()
|
||||
.ToHashSet();
|
||||
if (selectedDays.Count == 0)
|
||||
{
|
||||
return date.AddDays(repeatEvery * 7);
|
||||
}
|
||||
|
||||
var maxDays = Math.Max(7, repeatEvery * 7);
|
||||
for (var offset = 1; offset <= maxDays; offset++)
|
||||
{
|
||||
var candidate = date.AddDays(offset);
|
||||
if (selectedDays.Contains(candidate.DayOfWeek))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return date.AddDays(repeatEvery * 7);
|
||||
}
|
||||
|
||||
private static DayOfWeek? ParseDayOfWeek(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"SU" => DayOfWeek.Sunday,
|
||||
"MO" => DayOfWeek.Monday,
|
||||
"TU" => DayOfWeek.Tuesday,
|
||||
"WE" => DayOfWeek.Wednesday,
|
||||
"TH" => DayOfWeek.Thursday,
|
||||
"FR" => DayOfWeek.Friday,
|
||||
"SA" => DayOfWeek.Saturday,
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static string GetStatus(DateOnly? date, DateOnly today)
|
||||
{
|
||||
|
||||
@@ -166,6 +166,14 @@ namespace plant_manager.Data
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.EveryDays).IsRequired();
|
||||
entity.Property(e => e.ScheduledFor);
|
||||
entity.Property(e => e.RecurrenceMode).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.RepeatEvery).IsRequired();
|
||||
entity.Property(e => e.RepeatUnit).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.RepeatOnDays).HasMaxLength(40);
|
||||
entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.EndsOn);
|
||||
entity.Property(e => e.EndsAfterOccurrences);
|
||||
entity.Property(e => e.IsEnabled).IsRequired();
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique(false);
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActivityId }).IsUnique();
|
||||
|
||||
+32
-1
@@ -11,7 +11,7 @@ using plant_manager.Data;
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260520180017_InitialCreate")]
|
||||
[Migration("20260522001415_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -252,6 +252,17 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EndsAfterOccurrences")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EndsMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("EndsOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -261,6 +272,26 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RepeatEvery")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RepeatOnDays")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RepeatUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("ScheduledFor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
+8
@@ -231,6 +231,14 @@ namespace plant_manager.Data.Migrations
|
||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
@@ -249,6 +249,17 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EndsAfterOccurrences")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EndsMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("EndsOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -258,6 +269,26 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RepeatEvery")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RepeatOnDays")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RepeatUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("ScheduledFor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
@@ -7,6 +7,14 @@ namespace plant_manager.Data.Models
|
||||
public int CareActionId { get; set; }
|
||||
public int CareActivityId { get; set; }
|
||||
public int EveryDays { get; set; } = 7;
|
||||
public DateOnly? ScheduledFor { get; set; }
|
||||
public string RecurrenceMode { get; set; } = "weekly";
|
||||
public int RepeatEvery { get; set; } = 1;
|
||||
public string RepeatUnit { get; set; } = "week";
|
||||
public string? RepeatOnDays { get; set; }
|
||||
public string EndsMode { get; set; } = "after";
|
||||
public DateOnly? EndsOn { get; set; }
|
||||
public int? EndsAfterOccurrences { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public Plant Plant { get; set; } = null!;
|
||||
|
||||
@@ -34,17 +34,22 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
group.Key.PlantId,
|
||||
group.Key.CareActivityId,
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn)
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn),
|
||||
CompletedOccurrences = group.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
var latestLogLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => (DateOnly?)log.LastPerformedOn);
|
||||
var completedLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => log.CompletedOccurrences);
|
||||
|
||||
var tasks = schedules
|
||||
.Select(schedule => CareTaskDto.FromSchedule(
|
||||
schedule,
|
||||
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
||||
completedLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
||||
today))
|
||||
.Where(task => task.Status is "due" or "soon")
|
||||
.ToList();
|
||||
@@ -108,18 +113,23 @@ namespace plant_manager.Endpoints
|
||||
.Select(group => new
|
||||
{
|
||||
PlantId = group.Key,
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn)
|
||||
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 duePlantIds = schedules
|
||||
.Where(schedule =>
|
||||
PlantCareFormatter.GetStatus(
|
||||
PlantCareFormatter.GetNextCareDate(
|
||||
schedule,
|
||||
latestLogLookup.GetValueOrDefault(schedule.PlantId),
|
||||
schedule.EveryDays),
|
||||
completedLookup.GetValueOrDefault(schedule.PlantId)),
|
||||
today) == "due")
|
||||
.Select(schedule => schedule.PlantId)
|
||||
.ToHashSet();
|
||||
|
||||
@@ -45,7 +45,16 @@ namespace plant_manager.Endpoints
|
||||
return Results.BadRequest(new { error = "One or more plants were not found." });
|
||||
}
|
||||
|
||||
var everyDays = Math.Clamp(request.EveryDays ?? 7, 1, 365);
|
||||
var recurrence = NormalizeRecurrence(
|
||||
request.RecurrenceMode,
|
||||
request.RepeatEvery,
|
||||
request.RepeatUnit,
|
||||
request.RepeatOnDays,
|
||||
request.EndsMode,
|
||||
request.EndsOn,
|
||||
request.EndsAfterOccurrences,
|
||||
request.ScheduledFor,
|
||||
request.EveryDays);
|
||||
foreach (var plant in plants)
|
||||
{
|
||||
var schedule = plant.CareSchedules
|
||||
@@ -63,7 +72,7 @@ namespace plant_manager.Endpoints
|
||||
|
||||
schedule.CareActionId = primaryAction.Id;
|
||||
schedule.CareActivityId = activity.Id;
|
||||
schedule.EveryDays = everyDays;
|
||||
ApplyRecurrence(schedule, recurrence);
|
||||
schedule.IsEnabled = request.IsEnabled;
|
||||
}
|
||||
|
||||
@@ -71,6 +80,125 @@ namespace plant_manager.Endpoints
|
||||
|
||||
return Results.Ok(new { updated = plants.Count });
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-care-schedules/bulk-remove", async (
|
||||
BulkSavePlantCareScheduleRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var plantIds = request.PlantIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (plantIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||
}
|
||||
|
||||
if (request.CareActivityId <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity is required." });
|
||||
}
|
||||
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Where(schedule =>
|
||||
schedule.CareActivityId == request.CareActivityId
|
||||
&& plantIds.Contains(schedule.PlantId))
|
||||
.ToListAsync();
|
||||
|
||||
db.PlantCareSchedules.RemoveRange(schedules);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { removed = schedules.Count });
|
||||
});
|
||||
}
|
||||
|
||||
internal static ScheduleRecurrence NormalizeRecurrence(
|
||||
string? recurrenceMode,
|
||||
int? repeatEvery,
|
||||
string? repeatUnit,
|
||||
string? repeatOnDays,
|
||||
string? endsMode,
|
||||
DateOnly? endsOn,
|
||||
int? endsAfterOccurrences,
|
||||
DateOnly? scheduledFor,
|
||||
int? everyDays)
|
||||
{
|
||||
var mode = NormalizeOption(recurrenceMode, new HashSet<string> { "none", "daily", "weekly", "monthly", "yearly", "custom" }, scheduledFor is null ? "weekly" : "none");
|
||||
var unit = NormalizeOption(repeatUnit, new HashSet<string> { "day", "week", "month", "year" }, mode switch
|
||||
{
|
||||
"daily" => "day",
|
||||
"weekly" => "week",
|
||||
"monthly" => "month",
|
||||
"yearly" => "year",
|
||||
_ => "week"
|
||||
});
|
||||
var every = mode switch
|
||||
{
|
||||
"daily" or "weekly" or "monthly" or "yearly" => 1,
|
||||
"none" => 1,
|
||||
_ => Math.Clamp(repeatEvery ?? 1, 1, 365)
|
||||
};
|
||||
var normalizedEndsMode = mode == "none"
|
||||
? "after"
|
||||
: NormalizeOption(endsMode, new HashSet<string> { "on", "after" }, "after");
|
||||
var normalizedEveryDays = mode switch
|
||||
{
|
||||
"none" => Math.Clamp(everyDays ?? 7, 1, 365),
|
||||
"daily" => 1,
|
||||
"weekly" => 7,
|
||||
"monthly" => 30,
|
||||
"yearly" => 365,
|
||||
"custom" => unit switch
|
||||
{
|
||||
"day" => every,
|
||||
"week" => every * 7,
|
||||
"month" => every * 30,
|
||||
"year" => every * 365,
|
||||
_ => Math.Clamp(everyDays ?? 7, 1, 365)
|
||||
},
|
||||
_ => Math.Clamp(everyDays ?? 7, 1, 365)
|
||||
};
|
||||
|
||||
return new ScheduleRecurrence(
|
||||
normalizedEveryDays,
|
||||
scheduledFor,
|
||||
mode,
|
||||
every,
|
||||
unit,
|
||||
string.IsNullOrWhiteSpace(repeatOnDays) ? null : repeatOnDays.Trim(),
|
||||
normalizedEndsMode,
|
||||
normalizedEndsMode == "on" ? endsOn : null,
|
||||
normalizedEndsMode == "after" ? Math.Clamp(mode == "none" ? 1 : endsAfterOccurrences ?? 12, 1, 999) : null);
|
||||
}
|
||||
|
||||
internal static void ApplyRecurrence(PlantCareSchedule schedule, ScheduleRecurrence recurrence)
|
||||
{
|
||||
schedule.EveryDays = recurrence.EveryDays;
|
||||
schedule.ScheduledFor = recurrence.ScheduledFor;
|
||||
schedule.RecurrenceMode = recurrence.RecurrenceMode;
|
||||
schedule.RepeatEvery = recurrence.RepeatEvery;
|
||||
schedule.RepeatUnit = recurrence.RepeatUnit;
|
||||
schedule.RepeatOnDays = recurrence.RepeatOnDays;
|
||||
schedule.EndsMode = recurrence.EndsMode;
|
||||
schedule.EndsOn = recurrence.EndsOn;
|
||||
schedule.EndsAfterOccurrences = recurrence.EndsAfterOccurrences;
|
||||
}
|
||||
|
||||
private static string NormalizeOption(string? value, IReadOnlySet<string> allowed, string fallback)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim().ToLower();
|
||||
return allowed.Contains(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
internal sealed record ScheduleRecurrence(
|
||||
int EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string RecurrenceMode,
|
||||
int RepeatEvery,
|
||||
string RepeatUnit,
|
||||
string? RepeatOnDays,
|
||||
string EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,14 @@ namespace plant_manager.Endpoints
|
||||
new SavePlantCareScheduleRequest(
|
||||
waterActivity.Id,
|
||||
7,
|
||||
null,
|
||||
"weekly",
|
||||
1,
|
||||
"week",
|
||||
null,
|
||||
"after",
|
||||
null,
|
||||
12,
|
||||
true)
|
||||
];
|
||||
}
|
||||
@@ -262,7 +270,16 @@ namespace plant_manager.Endpoints
|
||||
schedule.CareActivityId = activity.Id;
|
||||
schedule.CareAction = primaryAction;
|
||||
schedule.CareActivity = activity;
|
||||
schedule.EveryDays = Math.Clamp(requestedSchedule.EveryDays ?? 7, 1, 365);
|
||||
PlantCareScheduleEndpoints.ApplyRecurrence(schedule, PlantCareScheduleEndpoints.NormalizeRecurrence(
|
||||
requestedSchedule.RecurrenceMode,
|
||||
requestedSchedule.RepeatEvery,
|
||||
requestedSchedule.RepeatUnit,
|
||||
requestedSchedule.RepeatOnDays,
|
||||
requestedSchedule.EndsMode,
|
||||
requestedSchedule.EndsOn,
|
||||
requestedSchedule.EndsAfterOccurrences,
|
||||
requestedSchedule.ScheduledFor,
|
||||
requestedSchedule.EveryDays));
|
||||
schedule.IsEnabled = requestedSchedule.IsEnabled;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user