improve scheduling

This commit is contained in:
2026-05-22 01:36:17 +00:00
parent eb5471ca27
commit 193940ac17
19 changed files with 1144 additions and 254 deletions
+8
View File
@@ -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`
+27 -83
View File
@@ -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);
}
+7
View File
@@ -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',
+48 -48
View File
@@ -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}`;
}
+28
View File
@@ -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;
};
+23
View File
@@ -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,
};
}
+192 -34
View File
@@ -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
View File
@@ -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();
@@ -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");
@@ -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!;
+13 -3
View File
@@ -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);
}
}
+18 -1
View File
@@ -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;
}