diff --git a/app/assets/stylesheets/_plantings.scss b/app/assets/stylesheets/_plantings.scss index 9c3a47c28..97d69d116 100755 --- a/app/assets/stylesheets/_plantings.scss +++ b/app/assets/stylesheets/_plantings.scss @@ -176,13 +176,16 @@ // Step 1 of 2 / step 2 of 2. .plant-steps { display: flex; + flex-wrap: wrap; margin: 0 0 1.5rem; padding: 0; list-style: none; + row-gap: 0.5rem; .plant-step { display: flex; align-items: center; + white-space: nowrap; margin-right: 1.5rem; color: #9e9e9e; font-size: 0.95rem; @@ -279,3 +282,47 @@ opacity: 0.7; } } + +// Record harvest, step 3: Today / Yesterday / Enter date, as pills. The radios +// are visually hidden, so the pill shows which one has keyboard focus. +.when-legend { + float: none; + width: auto; +} + +.when-choices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .when-choice { + margin: 0; + padding: 0.5rem 1.5rem; + border: 2px solid #9e9e9e; + border-radius: 2rem; + color: #212529; + font-size: 1.1rem; + cursor: pointer; + } + + input:checked + .when-choice { + border-color: #43a047; + background: #43a047; + color: #fff; + font-weight: bold; + } + + input:focus-visible + .when-choice { + outline: 2px solid #1976d2; + outline-offset: 2px; + } +} + +.when-hint { + margin: 0.75rem 0 0; + color: #757575; +} + +.when-date { + width: auto; +} diff --git a/app/javascript/components/RecordHarvestModal.jsx b/app/javascript/components/RecordHarvestModal.jsx index 24d207425..093e31060 100644 --- a/app/javascript/components/RecordHarvestModal.jsx +++ b/app/javascript/components/RecordHarvestModal.jsx @@ -1,11 +1,13 @@ import React, {useEffect, useState} from 'react'; import {getJson, postJson} from '../api'; +import {formatDate} from '../dates'; import Modal from './Modal'; import PlantPartPicker from './PlantPartPicker'; import Steps from './Steps'; -const STEPS = ['Choose a part', 'How much?']; +const STEPS = ['Choose a part', 'How much?', 'When?', 'Any notes?']; +const WHEN_CHOICES = [['today', 'Today'], ['yesterday', 'Yesterday'], ['other', 'Enter date']]; function humanize(field) { return field.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()); @@ -20,16 +22,26 @@ function errorMessages(status, data) { return ['Something went wrong saving that. Please try again.']; } -// Recording a harvest from a garden card, as a dialog over the list, in the same -// two steps as the Add planting dialog: say which part of the plant you -// harvested, then how much. The crop is the planting's and the date is today. -// It loads those, and the choices, from /plantings/:slug/harvests/new.json, saves +// "2026-09-21" a number of days earlier, as "2026-09-20". Worked out in UTC so +// the browser's time zone can't shift it. +function daysBefore(iso, days) { + const [year, month, day] = iso.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day - days)).toISOString().slice(0, 10); +} + +// Recording a harvest from a garden card, as a dialog over the list, in steps +// like the Add planting dialog: which part of the plant you harvested, how +// much, when (today unless you say otherwise), then any notes, and save. The +// crop is the planting's. +// It loads that, and the choices, from /plantings/:slug/harvests/new.json, saves // with POST /harvests.json, and hands the refreshed garden card to onSaved, so // the planting's badges follow and the page is never left. export default function RecordHarvestModal({planting, iconUrl, onClose, onSaved}) { const [form, setForm] = useState(null); // {planting_id, plant_parts, plant_part_id (the usual one), units, weight_units, ...} const [values, setValues] = useState(null); const [part, setPart] = useState(null); // step 1's answer + const [step, setStep] = useState(1); + const [when, setWhen] = useState('today'); // today, yesterday, or other (a date you enter) const [loadError, setLoadError] = useState(false); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState([]); @@ -42,7 +54,7 @@ export default function RecordHarvestModal({planting, iconUrl, onClose, onSaved} if (!ok) throw new Error('load failed'); setForm(data); setValues({ - harvested_at: data.harvested_at || '', + custom_date: '', quantity: '', unit: data.units[0].value, weight_quantity: '', @@ -58,13 +70,48 @@ export default function RecordHarvestModal({planting, iconUrl, onClose, onSaved} const set = (field) => (event) => setValues((current) => ({...current, [field]: event.target.value})); + // The server's "today" is the one that counts, not the browser's. + const today = form && form.harvested_at; + const harvestedAt = {today, yesterday: today && daysBefore(today, 1), other: values && values.custom_date}[when]; + + function choosePart(chosen) { + setPart(chosen); + setStep(2); + } + + function chooseWhen(choice) { + setWhen(choice); + if (choice === 'other' && !values.custom_date) setValues((current) => ({...current, custom_date: today})); + } + + function amount() { + const unit = form.units.find((candidate) => candidate.value === values.unit); + const parts = [ + values.quantity && `${values.quantity} ${unit ? unit.label : values.unit}`, + values.weight_quantity && `${values.weight_quantity} ${values.weight_unit}`, + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : 'Not entered'; + } + + // "Today, 21 Sep", or just the date when you entered one. + function whenLabel() { + const date = formatDate(harvestedAt); + return when === 'other' ? date : `${WHEN_CHOICES.find(([value]) => value === when)[1]}, ${date}`; + } + + // Each step's button goes on to the next; the last one saves. async function save(event) { event.preventDefault(); + if (step < STEPS.length) { + setStep(step + 1); + return; + } setSaving(true); setErrors([]); try { + const {custom_date: customDate, ...fields} = values; const {ok, status, data} = await postJson('/harvests.json', { - harvest: {planting_id: form.planting_id, plant_part_id: part.id, ...values}, + harvest: {planting_id: form.planting_id, plant_part_id: part.id, harvested_at: harvestedAt, ...fields}, }); if (ok) { onSaved(data.garden); // closes this dialog @@ -94,21 +141,16 @@ export default function RecordHarvestModal({planting, iconUrl, onClose, onSaved} {!loadError && !values && (