diff --git a/client/src/components/ChoreFormDialog.jsx b/client/src/components/ChoreFormDialog.jsx new file mode 100644 index 00000000..69ece440 --- /dev/null +++ b/client/src/components/ChoreFormDialog.jsx @@ -0,0 +1,338 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + IconButton, + InputLabel, + ListItemText, + MenuItem, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { Add, Cancel, Delete, Save, Schedule } from '@mui/icons-material'; +import axios from 'axios'; +import { useTranslation } from 'react-i18next'; +import { API_BASE_URL } from '../utils/apiConfig.js'; +import { editWholeNumber, savedWholeNumber } from '../utils/wholeNumberField.js'; +import useIsMobile from '../hooks/useIsMobile.js'; +import ChoreIconPicker from './ChoreIconPicker.jsx'; +import { + MAX_FOLLOWUPS, + defaultChoreForm, + followupFormError, + followupRowFromRule, + followupRuleFromRow, + newFollowupRow, +} from '../utils/choreFollowups.js'; + +// The admin console's New Chore dialog — and the widget's Add Chore dialog +// too: this is the one implementation both entries render, not two copies. +// Identity first (title, description, clam value, icon — the icon belongs +// to the chore, not the schedule, so every schedule of it inherits it, +// issue #141), then follow-ups (issue #241), then either Create on its own +// or Schedule, which hands the saved chore to ScheduleChoreDialog. +// +// Hosts differ only in plumbing, passed in as props: `onSaved` refreshes +// the host's lists (and, in Admin, resets the person filter for a new +// chore), `onSchedule` opens the host's ScheduleChoreDialog for the saved +// chore, and `showMessage` is the host's toast (Admin) or error-only alert +// (the widget has no toast system). `chores` is the list a follow-up may +// give — Admin passes its non-routine chores, the widget its non-routine +// ones — and `users` the people a follow-up may go to. +// +// `scheduleOnly` (the widget): Schedule is the only way to save, and Enter +// does the same. The dashboard shows only scheduled chores and has no toast, +// so a chore saved with Create alone would seem to vanish. +export default function ChoreFormDialog({ open, chore, chores, users, offerSchedule, scheduleOnly = false, onClose, onSaved, onSchedule, showMessage }) { + const { t } = useTranslation(['chores', 'common']); + const isMobile = useIsMobile(); + const [choreForm, setChoreForm] = useState(defaultChoreForm); + // The chore dialog's follow-ups, as form rows (issue #241). + const [followupRows, setFollowupRows] = useState([]); + const [followupError, setFollowupError] = useState(''); + const [savingChore, setSavingChore] = useState(false); + // The chore being edited — null while creating — or the id a new chore was + // saved under, so a follow-up problem below can be fixed and saved again + // without a duplicate. + const [savedChore, setSavedChore] = useState(null); + + // (Re)opening starts over: a fresh form for a new chore, the chore's own + // values plus its saved follow-ups for an edit. + useEffect(() => { + if (!open) return; + setSavedChore(chore || null); + setChoreForm(chore + ? { title: chore.title, description: chore.description || '', clam_value: chore.clam_value || 0, icon: chore.icon || '' } + : defaultChoreForm); + setFollowupRows([]); + setFollowupError(''); + if (chore?.id) { + axios.get(`${API_BASE_URL}/api/chores/${chore.id}/followups`) + .then((res) => setFollowupRows((res.data || []).map(followupRowFromRule))) + .catch(() => setFollowupRows([])); + } + }, [open, chore]); + + const getUserName = (userId) => { + if (userId === null || userId === undefined || userId === 0) return 'Unassigned'; + const user = users.find(u => u.id === userId); + return user ? user.username : t('chores:schedules.unknownUser', { id: userId }); + }; + + const updateFollowupRow = (index, changes) => { + setFollowupError(''); + setFollowupRows((rows) => rows.map((row, i) => (i === index ? { ...row, ...changes } : row))); + }; + + // With thenSchedule, a saved chore goes straight on to the Schedule flow. + const handleSaveChore = async (thenSchedule = false) => { + if (!choreForm.title.trim()) return; + const formError = followupFormError(followupRows); + if (formError) { setFollowupError(t(formError.key, { n: formError.n })); return; } + setSavingChore(true); + // A cleared clam field edits as ''; the server gets 0 for it, as before. + const chorePayload = { ...choreForm, clam_value: savedWholeNumber(choreForm.clam_value) }; + const editing = savedChore; + let choreId = editing?.id; + try { + if (editing) { + await axios.patch(`${API_BASE_URL}/api/chores/${editing.id}`, chorePayload); + } else { + const res = await axios.post(`${API_BASE_URL}/api/chores`, chorePayload); + choreId = res.data?.id; + // Saved: from here on this dialog is editing it, so a follow-up + // problem below can be fixed and saved again without a duplicate. + if (choreId) setSavedChore({ id: choreId, ...chorePayload }); + } + } catch (err) { + showMessage('error', err.response?.data?.error || 'Failed to save chore.'); + setSavingChore(false); + return; + } + const saved = { id: choreId, ...chorePayload }; + try { + if (choreId && (editing || followupRows.length)) { + await axios.put(`${API_BASE_URL}/api/chores/${choreId}/followups`, { + followups: followupRows.map(followupRuleFromRow), + }); + } + showMessage('success', editing ? 'Chore updated.' : 'Chore created.'); + onClose(); + onSaved(saved, { isNew: !editing }); + if (thenSchedule && choreId) onSchedule(saved); + } catch (err) { + // The chore itself is saved; the dialog stays open on the follow-ups, + // and the host's list refreshes so the saved chore shows. + setFollowupError(err.response?.data?.error || t('chores:followups.errorSave')); + onSaved(saved, { isNew: !editing }); + } finally { + setSavingChore(false); + } + }; + + return ( + { + event.preventDefault(); + handleSaveChore(scheduleOnly); + }, + } + }} + > + {savedChore ? t('chores:schedules.editChore') : t('chores:schedules.newChore')} + + + setChoreForm(f => ({ ...f, title: e.target.value }))} + required + /> + setChoreForm(f => ({ ...f, description: e.target.value }))} + multiline + rows={2} + /> + setChoreForm(f => ({ ...f, clam_value: editWholeNumber(e.target.value) }))} + slotProps={{ htmlInput: { min: 0 } }} + sx={{ width: { xs: '100%', sm: 140 } }} + /> + {/* The icon belongs to the chore, not the schedule, so it is picked + here and every schedule of this chore inherits it (issue #141). */} + setChoreForm(f => ({ ...f, icon }))} + /> + + {/* Follow-ups (issue #241). Nothing but one button until used. */} + + + + {t('chores:followups.heading')}{' '} + {t('chores:followups.optional')} + + {followupRows.length > 0 && ( + + {t('chores:followups.help')} + + )} + + {followupRows.map((row, index) => ( + + + + {t('chores:followups.give')} + + + + { setFollowupError(''); setFollowupRows((rows) => rows.filter((_, i) => i !== index)); }} + > + + + + + + {t('chores:followups.to')} + + + + + {t('chores:followups.when')} + + + {row.timing === 'after' && ( + <> + updateFollowupRow(index, { amount: e.target.value })} + slotProps={{ htmlInput: { min: 1, 'aria-label': t('chores:followups.amount') } }} + sx={{ width: 90 }} + /> + + + + + )} + + + ))} + + {followupError && {followupError}} + {followupRows.length < MAX_FOLLOWUPS && ( + + )} + + + + + + {!scheduleOnly && ( + + )} + {(offerSchedule || scheduleOnly) && ( + + )} + + + ); +} diff --git a/client/src/components/ChoreSchedulesTab.jsx b/client/src/components/ChoreSchedulesTab.jsx index 86a3f36d..a396b57e 100644 --- a/client/src/components/ChoreSchedulesTab.jsx +++ b/client/src/components/ChoreSchedulesTab.jsx @@ -40,11 +40,9 @@ import { } from '@mui/icons-material'; import axios from 'axios'; import { API_BASE_URL } from '../utils/apiConfig.js'; -import { editWholeNumber, savedWholeNumber } from '../utils/wholeNumberField.js'; import { CronExpressionParser } from 'cron-parser'; import { getServerTimezoneSync } from '../utils/timezone.js'; import SoundPicker from './SoundPicker.jsx'; -import ChoreIconPicker from './ChoreIconPicker.jsx'; import { useTranslation } from 'react-i18next'; import { AdminGroup, AdminNote, AdminRow, AdminSortControl } from './AdminRows.jsx'; import { getWeekdayLabels } from '../utils/dateUtils.js'; @@ -52,6 +50,7 @@ import useIsMobile from '../hooks/useIsMobile.js'; import { compareByKey } from '../utils/choreHelpers.js'; import ExpiresSelect from './ExpiresSelect.jsx'; import ChoreScheduleBoard from './ChoreScheduleBoard.jsx'; +import ChoreFormDialog from './ChoreFormDialog.jsx'; import ScheduleChoreDialog from './ScheduleChoreDialog.jsx'; import RoutinesGroup from './RoutinesGroup.jsx'; import ChoreHistoryTab from './ChoreHistoryTab.jsx'; @@ -245,30 +244,6 @@ const defaultScheduleForm = { can_snooze: true }; -const defaultChoreForm = { title: '', description: '', clam_value: 0, icon: '' }; - -// Follow-ups (issue #241): "when this is done, give that chore to them". -const MAX_FOLLOWUPS = 3; -const newFollowupRow = () => ({ followup_chore_id: '', user_ids: [], timing: 'now', amount: 1, unit: 'hours' }); -// The server stores a delay in minutes; the form shows whole hours when it can. -const followupRowFromRule = (rule) => { - const minutes = rule.delay_minutes || 0; - if (!minutes) return { followup_chore_id: rule.followup_chore_id, user_ids: rule.user_ids, timing: 'now', amount: 1, unit: 'hours' }; - const hours = minutes % 60 === 0; - return { - followup_chore_id: rule.followup_chore_id, - user_ids: rule.user_ids, - timing: 'after', - amount: hours ? minutes / 60 : minutes, - unit: hours ? 'hours' : 'minutes', - }; -}; -const followupRuleFromRow = (row) => ({ - followup_chore_id: row.followup_chore_id, - user_ids: row.user_ids, - delay_minutes: row.timing === 'after' ? Math.round(Number(row.amount) * (row.unit === 'hours' ? 60 : 1)) : 0, -}); - // Clicking the active column flips it; clicking a new one starts ascending, // which is the reading order for names and "soonest first" for dates. function nextSort(sort, column) { @@ -291,9 +266,6 @@ export default function ChoreSchedulesTab({ setSaveMessage, section, onSection, const chores = useMemo(() => allChores.filter((c) => !c.routine_id), [allChores]); const schedules = useMemo(() => schedulesWithRoutines(allSchedules, routines), [allSchedules, routines]); const [routineEditor, setRoutineEditor] = useState({ open: false, routine: null, copyOf: null }); - // The chore dialog's follow-ups, as form rows (issue #241). - const [followupRows, setFollowupRows] = useState([]); - const [followupError, setFollowupError] = useState(''); const [loading, setLoading] = useState(true); const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false); @@ -305,13 +277,11 @@ export default function ChoreSchedulesTab({ setSaveMessage, section, onSection, const [choreDialogOpen, setChoreDialogOpen] = useState(false); const [editingChore, setEditingChore] = useState(null); - const [choreForm, setChoreForm] = useState(defaultChoreForm); const [deleteChoreDialog, setDeleteChoreDialog] = useState({ open: false, chore: null }); // Chores ticked for deleting together. const [selectedChoreIds, setSelectedChoreIds] = useState(new Set()); const [bulkDeleteDialog, setBulkDeleteDialog] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); - const [savingChore, setSavingChore] = useState(false); // The chore dialog's Schedule button: offered for a new chore, except one // made from the board's Add, which is already scheduling it. It stays if a // follow-up problem keeps the saved chore's dialog open. @@ -586,94 +556,18 @@ export default function ChoreSchedulesTab({ setSaveMessage, section, onSection, }; // `fromBoard` is true only from the board; a click handler passes an event. + // The form itself lives in ChoreFormDialog; this only says which chore it + // edits (none for a new one) and whether to offer its Schedule button. const openCreateChore = (fromBoard) => { setOfferSchedule(fromBoard !== true); setEditingChore(null); - setChoreForm(defaultChoreForm); - setFollowupRows([]); - setFollowupError(''); setChoreDialogOpen(true); }; const openEditChore = (chore) => { setOfferSchedule(false); setEditingChore(chore); - setChoreForm({ - title: chore.title, - description: chore.description || '', - clam_value: chore.clam_value || 0, - icon: chore.icon || '', - }); - setFollowupRows([]); - setFollowupError(''); setChoreDialogOpen(true); - axios.get(`${API_BASE_URL}/api/chores/${chore.id}/followups`) - .then((res) => setFollowupRows((res.data || []).map(followupRowFromRule))) - .catch(() => setFollowupRows([])); - }; - - const updateFollowupRow = (index, changes) => { - setFollowupError(''); - setFollowupRows((rows) => rows.map((row, i) => (i === index ? { ...row, ...changes } : row))); - }; - - // What the form can check before anything is saved; the server checks the - // rest (loops, people who no longer exist). - const followupFormError = () => { - for (const [index, row] of followupRows.entries()) { - const n = index + 1; - if (!row.followup_chore_id) return t('chores:followups.errorChore', { n }); - if (!row.user_ids.length) return t('chores:followups.errorPeople', { n }); - const minutes = followupRuleFromRow(row).delay_minutes; - if (row.timing === 'after' && (!(minutes > 0) || minutes > 7 * 24 * 60)) return t('chores:followups.errorDelay', { n }); - } - return ''; - }; - - // With thenSchedule, a saved chore goes straight on to the Schedule flow. - const handleSaveChore = async (thenSchedule = false) => { - if (!choreForm.title.trim()) return; - const formError = followupFormError(); - if (formError) { setFollowupError(formError); return; } - setSavingChore(true); - // A cleared clam field edits as ''; the server gets 0 for it, as before. - const chorePayload = { ...choreForm, clam_value: savedWholeNumber(choreForm.clam_value) }; - let choreId = editingChore?.id; - try { - if (editingChore) { - await axios.patch(`${API_BASE_URL}/api/chores/${editingChore.id}`, chorePayload); - } else { - const res = await axios.post(`${API_BASE_URL}/api/chores`, chorePayload); - choreId = res.data?.id; - // Saved: from here on this dialog is editing it, so a follow-up - // problem below can be fixed and saved again without a duplicate. - if (choreId) setEditingChore({ id: choreId, ...chorePayload }); - // A new chore has no schedule yet, so a chip or search would hide it. - setPerson('all'); - setQuery(''); - } - } catch (err) { - showMessage('error', err.response?.data?.error || 'Failed to save chore.'); - setSavingChore(false); - return; - } - try { - if (choreId && (editingChore || followupRows.length)) { - await axios.put(`${API_BASE_URL}/api/chores/${choreId}/followups`, { - followups: followupRows.map(followupRuleFromRow), - }); - } - showMessage('success', editingChore ? 'Chore updated.' : 'Chore created.'); - setChoreDialogOpen(false); - await fetchAll(); - if (thenSchedule && choreId) setScheduleChore({ id: choreId, ...chorePayload }); - } catch (err) { - // The chore itself is saved; the dialog stays open on the follow-ups. - setFollowupError(err.response?.data?.error || t('chores:followups.errorSave')); - await fetchAll(); - } finally { - setSavingChore(false); - } }; const handleDeleteChore = async () => { @@ -1206,196 +1100,24 @@ export default function ChoreSchedulesTab({ setSaveMessage, section, onSection, )} {/* ── CHORE DIALOG ─────────────────────────────────── */} - setChoreDialogOpen(false)} - maxWidth="sm" - fullWidth - fullScreen={isMobile} - slotProps={{ - paper: { - component: 'form', - onSubmit: (event) => { - event.preventDefault(); - handleSaveChore(); - }, - } + onSaved={(saved, { isNew }) => { + if (isNew) { setPerson('all'); setQuery(''); } + fetchAll(); }} - > - {editingChore ? t('chores:schedules.editChore') : t('chores:schedules.newChore')} - - - setChoreForm(f => ({ ...f, title: e.target.value }))} - required - /> - setChoreForm(f => ({ ...f, description: e.target.value }))} - multiline - rows={2} - /> - setChoreForm(f => ({ ...f, clam_value: editWholeNumber(e.target.value) }))} - slotProps={{ htmlInput: { min: 0 } }} - sx={{ width: { xs: '100%', sm: 140 } }} - /> - {/* The icon belongs to the chore, not the schedule, so it is picked - here and every schedule of this chore inherits it (issue #141). */} - setChoreForm(f => ({ ...f, icon }))} - /> - - {/* Follow-ups (issue #241). Nothing but one button until used. */} - - - - {t('chores:followups.heading')}{' '} - {t('chores:followups.optional')} - - {followupRows.length > 0 && ( - - {t('chores:followups.help')} - - )} - - {followupRows.map((row, index) => ( - - - - {t('chores:followups.give')} - - - - { setFollowupError(''); setFollowupRows((rows) => rows.filter((_, i) => i !== index)); }} - > - - - - - - {t('chores:followups.to')} - - - - - {t('chores:followups.when')} - - - {row.timing === 'after' && ( - <> - updateFollowupRow(index, { amount: e.target.value })} - slotProps={{ htmlInput: { min: 1, 'aria-label': t('chores:followups.amount') } }} - sx={{ width: 90 }} - /> - - - - - )} - - - ))} - - {followupError && {followupError}} - {followupRows.length < MAX_FOLLOWUPS && ( - - )} - - - - - - - {offerSchedule && ( - - )} - - + onSchedule={(saved) => setScheduleChore(saved)} + showMessage={showMessage} + /> {/* ── SCHEDULE A NEW CHORE ─────────────────────────── */} { const [weekHistory, setWeekHistory] = useState([]); const [vacation, setVacation] = useState(null); const [prizes, setPrizes] = useState([]); - const [newChore, setNewChore] = useState({ - user_id: '', - title: '', - description: '', - assigned_days_of_week: ['monday'], - clam_value: 0, - icon: '', - is_one_time: false - }); + // Add Chore opens the same dialog as Admin → Chores' New Chore button + // (ChoreFormDialog.jsx): identity and follow-ups, saved only with Schedule + // here (scheduleOnly), since an unscheduled chore never shows on the wall. + // Who it is for and when it happens are picked in ScheduleChoreDialog, + // which opens once this is saved. The widget used to bake in one + // fixed schedule here (one person, weekly days or one-time, no due date, + // no duration), so it could not do every-N-days, monthly, any-day, + // repeat-after-completion, keep-until-completed, several people, or + // joining an existing schedule. const [showAddDialog, setShowAddDialog] = useState(false); + // The chore just created, while its schedules are picked in + // ScheduleChoreDialog; null when that flow is not open. + const [scheduleChore, setScheduleChore] = useState(null); + // The dialog's own schedules: the widget's list is usage=chart, which + // leaves out the sticky durations, so "join one of their schedules" would + // miss any-day / again-after-done / until-completed timings. This is the + // full list, as Admin → Chores passes it. + const [dialogSchedules, setDialogSchedules] = useState([]); const [showPrizesModal, setShowPrizesModal] = useState(false); const [loading, setLoading] = useState(true); const [showBonusChores, setShowBonusChores] = useState(DEFAULT_SHOW_BONUS_CHORES); @@ -190,8 +198,6 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { const longPressStartRef = useRef(null); const choreMenuOpenedAtRef = useRef(0); - const daysOfWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; - // Control Limits (resolved once in app.jsx and handed down). Hidden means NOT // RENDERED — never a disabled button and never a PIN prompt offered in place of // the action, because the point is that this display does not carry the control @@ -209,6 +215,8 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { useEffect(() => { if (hideAddChore) { setShowAddDialog((prev) => (prev ? false : prev)); + // The schedule flow is the second half of Add, so it closes too. + setScheduleChore((prev) => (prev ? null : prev)); } if (hideTransferChore) { setTransferDialog((prev) => (prev.open ? { ...prev, open: false } : prev)); @@ -1053,42 +1061,26 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }); }; - const saveChore = async () => { + // The chore the form dialog just saved with Schedule, while its schedules + // are picked in ScheduleChoreDialog — the same two-step flow as Admin → + // Chores' Schedule button. Who it is for and when it happens are picked + // there, with the same save path (one schedule per person, via + // schedulePayload), so the widget can now do every-N-days, monthly, + // any-day, repeat-after-completion, keep-until-completed, several people, + // and joining an existing schedule. Cancelling the schedule step leaves + // the chore unscheduled, as in Admin; it simply does not show on the wall + // until it has a schedule. + const openScheduleForChore = async (saved) => { + // The full schedule list, for "join one of their schedules" (see the + // dialogSchedules note above); a failed load only hides that section. try { - setIsLoading(true); - - const choreResponse = await axios.post(`${API_BASE_URL}/api/chores`, { - title: newChore.title, - description: newChore.description, - clam_value: savedWholeNumber(newChore.clam_value), - icon: newChore.icon - }); - - const choreId = choreResponse.data.id; - const crontab = newChore.is_one_time ? null : convertDaysToCrontab(newChore.assigned_days_of_week); - - await axios.post(`${API_BASE_URL}/api/chore-schedules`, { - chore_id: choreId, - user_id: newChore.user_id || null, - crontab: crontab, - visible: 1 - }); - - setNewChore({ - user_id: '', - title: '', - description: '', - assigned_days_of_week: ['monday'], - clam_value: 0, - is_one_time: false - }); - setShowAddDialog(false); - await fetchData(); + const res = await axios.get(`${API_BASE_URL}/api/chore-schedules`); + setDialogSchedules(Array.isArray(res.data) ? res.data : []); } catch (error) { - console.error('Error saving chore:', error); - } finally { - setIsLoading(false); + console.error('Error fetching schedules for the schedule dialog:', error); + setDialogSchedules([]); } + setScheduleChore(saved); }; const getUserChoresForToday = (userId) => { @@ -1481,15 +1473,6 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { ); }; - const handleDayToggle = (day) => { - setNewChore(prev => ({ - ...prev, - assigned_days_of_week: prev.assigned_days_of_week.includes(day) - ? prev.assigned_days_of_week.filter(d => d !== day) - : [...prev.assigned_days_of_week, day] - })); - }; - if (loading) { return ( { /> )} - !c.routine_id)} + users={users} + scheduleOnly onClose={() => setShowAddDialog(false)} - maxWidth="sm" - fullWidth - slotProps={{ - paper: { - component: 'form', - onSubmit: (event) => { - event.preventDefault(); - saveChore(); - }, - } - }} - > - {t('chores:add.dialogTitle')} - - setNewChore({ ...newChore, title: e.target.value })} - sx={{ mb: 2, mt: 1 }} - /> - setNewChore({ ...newChore, description: e.target.value })} - sx={{ mb: 2 }} - /> - - setNewChore({ ...newChore, icon })} - /> - - - {t('chores:add.assignToUser')} - - - - - setNewChore({ - ...newChore, - is_one_time: e.target.checked, - assigned_days_of_week: e.target.checked ? [] : ['monday'] - })} - color="primary" - /> - } - label={t('chores:add.oneTime')} - /> - + onSaved={() => fetchData()} + onSchedule={openScheduleForChore} + showMessage={(type, message) => { if (type === 'error') alert(message); }} + /> - {!newChore.is_one_time && ( - - - {t('chores:add.selectDays')} - - - {daysOfWeek.map(day => ( - handleDayToggle(day)} - color="primary" - /> - } - // Label is translated; `day` stays the English key that - // crontab conversion and the API depend on. - label={t(`chores:days.${day}`)} - /> - ))} - - - )} - setNewChore({ ...newChore, clam_value: editWholeNumber(e.target.value) })} - /> - - - - - - + {/* Who it is for and when it happens: the Admin → Chores schedule + flow, for the chore just created above. Its saves go through the + same schedulePayload as Admin, and onChanged reloads the widget. + There is no toast system on the dashboard, so only errors surface + (as an alert, as the widget's other error paths do); a successful + save simply closes the dialog and the chore appears. */} + setScheduleChore(null)} + onChanged={fetchData} + showMessage={(type, message) => { if (type === 'error') alert(message); }} + /> {/* Long-press / right-click chore menu */} { it('saves one person\'s schedule of the chore, shown', () => { @@ -27,6 +27,33 @@ describe('schedulePayload', () => { expect(body.duration).toBe('any-day'); expect(body.interval).toBe('1m'); }); + + // The widget's Add flow used to build this by hand; it now goes through + // ScheduleChoreDialog, so the 2026-10-05 once-completed case is pinned + // here: a one-time chore still saves with a null crontab (and its due + // date), never a fabricated one. + it('a one-time chore saves with a null crontab and its due date', () => { + const body = schedulePayload(7, 3, onceTiming('2026-11-20'), null); + expect(body.chore_id).toBe(7); + expect(body.user_id).toBe(3); + expect(body.crontab).toBeNull(); + expect(body.duration).toBe('day-of'); + expect(body.due_date).toBe('2026-11-20'); + }); + + it('a one-time chore with no date still saves with a null crontab', () => { + const body = schedulePayload(7, 3, onceTiming(null), null); + expect(body.crontab).toBeNull(); + expect(body.due_date).toBeNull(); + }); + + // Repeat-after-completion was unreachable from the widget's old Add flow. + it('repeat after completion carries its interval', () => { + const body = schedulePayload(7, 3, afterTiming('3d'), null); + expect(body.duration).toBe('once-completed'); + expect(body.interval).toBe('3d'); + expect(body.crontab).toBe('0 0 * * *'); + }); }); describe('customTiming', () => { diff --git a/client/src/i18n/locales/en/chores.json b/client/src/i18n/locales/en/chores.json index ace124cc..43525a33 100644 --- a/client/src/i18n/locales/en/chores.json +++ b/client/src/i18n/locales/en/chores.json @@ -109,14 +109,6 @@ "alreadyHasBonus": "User already has an uncompleted bonus chore. Complete it first!", "assignFailed": "Failed to assign bonus chore" }, - "add": { - "dialogTitle": "Add New Chore", - "assignToUser": "Assign to User", - "bonusChoreUnassigned": "Bonus Chore (Unassigned)", - "oneTime": "One-time chore (no recurrence)", - "selectDays": "Select Days (choose one or more):", - "clamValue": "🥟 Clam Value (0 for regular chore)" - }, "transfer": { "menuItem": "Transfer chore", "dialogTitle": "Who should take over this chore?", diff --git a/client/src/i18n/locales/es/chores.json b/client/src/i18n/locales/es/chores.json index 70341560..4310748d 100644 --- a/client/src/i18n/locales/es/chores.json +++ b/client/src/i18n/locales/es/chores.json @@ -109,14 +109,6 @@ "alreadyHasBonus": "Esta persona ya tiene una tarea extra sin terminar. ¡Complétala primero!", "assignFailed": "No se pudo asignar la tarea extra" }, - "add": { - "dialogTitle": "Añadir nueva tarea", - "assignToUser": "Asignar a", - "bonusChoreUnassigned": "Tarea extra (sin asignar)", - "oneTime": "Tarea única (sin repetición)", - "selectDays": "Elige los días (uno o más):", - "clamValue": "🥟 Valor en almejas (0 para tarea normal)" - }, "transfer": { "menuItem": "Transferir tarea", "dialogTitle": "¿Quién se encarga de esta tarea?", diff --git a/client/src/utils/choreFollowups.js b/client/src/utils/choreFollowups.js new file mode 100644 index 00000000..4ff20b76 --- /dev/null +++ b/client/src/utils/choreFollowups.js @@ -0,0 +1,44 @@ +// Follow-ups (issue #241): "when this is done, give that chore to them". +// The admin console's New Chore dialog and the widget's Add Chore dialog +// are the same component (ChoreFormDialog.jsx); these are its form helpers, +// kept pure so they can be tested without rendering the dialog. + +export const defaultChoreForm = { title: '', description: '', clam_value: 0, icon: '' }; + +export const MAX_FOLLOWUPS = 3; + +export const newFollowupRow = () => ({ followup_chore_id: '', user_ids: [], timing: 'now', amount: 1, unit: 'hours' }); + +// The server stores a delay in minutes; the form shows whole hours when it can. +export const followupRowFromRule = (rule) => { + const minutes = rule.delay_minutes || 0; + if (!minutes) return { followup_chore_id: rule.followup_chore_id, user_ids: rule.user_ids, timing: 'now', amount: 1, unit: 'hours' }; + const hours = minutes % 60 === 0; + return { + followup_chore_id: rule.followup_chore_id, + user_ids: rule.user_ids, + timing: 'after', + amount: hours ? minutes / 60 : minutes, + unit: hours ? 'hours' : 'minutes', + }; +}; + +export const followupRuleFromRow = (row) => ({ + followup_chore_id: row.followup_chore_id, + user_ids: row.user_ids, + delay_minutes: row.timing === 'after' ? Math.round(Number(row.amount) * (row.unit === 'hours' ? 60 : 1)) : 0, +}); + +// What the form can check before anything is saved; the server checks the +// rest (loops, people who no longer exist). Returns '' when the rows are +// fine, or { key, n } for the caller to translate. +export function followupFormError(rows) { + for (const [index, row] of rows.entries()) { + const n = index + 1; + if (!row.followup_chore_id) return { key: 'chores:followups.errorChore', n }; + if (!row.user_ids.length) return { key: 'chores:followups.errorPeople', n }; + const minutes = followupRuleFromRow(row).delay_minutes; + if (row.timing === 'after' && (!(minutes > 0) || minutes > 7 * 24 * 60)) return { key: 'chores:followups.errorDelay', n }; + } + return ''; +} diff --git a/client/src/utils/choreFollowups.test.js b/client/src/utils/choreFollowups.test.js new file mode 100644 index 00000000..2355b98b --- /dev/null +++ b/client/src/utils/choreFollowups.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { + MAX_FOLLOWUPS, + defaultChoreForm, + followupFormError, + followupRowFromRule, + followupRuleFromRow, + newFollowupRow, +} from './choreFollowups.js'; + +// The admin console's New Chore dialog and the widget's Add Chore dialog +// are the same component (ChoreFormDialog.jsx); these are its form helpers. +// A follow-up row must survive the round trip to the server's minutes and +// back, and an invalid row must be caught before anything is saved. +describe('followup row <-> rule', () => { + it('a row with no delay stores 0 minutes and reads back as "now"', () => { + const row = { followup_chore_id: 5, user_ids: [1], timing: 'now', amount: 1, unit: 'hours' }; + expect(followupRuleFromRow(row)).toEqual({ followup_chore_id: 5, user_ids: [1], delay_minutes: 0 }); + expect(followupRowFromRule({ followup_chore_id: 5, user_ids: [1], delay_minutes: 0 })).toEqual(row); + }); + + it('whole hours round-trip as hours, odd minutes as minutes', () => { + expect(followupRowFromRule({ followup_chore_id: 5, user_ids: [1], delay_minutes: 120 })) + .toEqual({ followup_chore_id: 5, user_ids: [1], timing: 'after', amount: 2, unit: 'hours' }); + expect(followupRowFromRule({ followup_chore_id: 5, user_ids: [1], delay_minutes: 90 })) + .toEqual({ followup_chore_id: 5, user_ids: [1], timing: 'after', amount: 90, unit: 'minutes' }); + expect(followupRuleFromRow({ followup_chore_id: 5, user_ids: [1], timing: 'after', amount: 2, unit: 'hours' }).delay_minutes).toBe(120); + expect(followupRuleFromRow({ followup_chore_id: 5, user_ids: [1], timing: 'after', amount: 90, unit: 'minutes' }).delay_minutes).toBe(90); + }); + + it('a new row starts empty, untimed, with one hour', () => { + expect(newFollowupRow()).toEqual({ followup_chore_id: '', user_ids: [], timing: 'now', amount: 1, unit: 'hours' }); + expect(MAX_FOLLOWUPS).toBe(3); + }); + + it('a new chore form starts blank with a zero clam value', () => { + expect(defaultChoreForm).toEqual({ title: '', description: '', clam_value: 0, icon: '' }); + }); +}); + +describe('followupFormError', () => { + const valid = { followup_chore_id: 5, user_ids: [1, 2], timing: 'after', amount: 2, unit: 'hours' }; + + it('no rows is fine', () => { + expect(followupFormError([])).toBe(''); + }); + + it('a valid row is fine', () => { + expect(followupFormError([valid])).toBe(''); + }); + + it('a row with no chore to give names the row', () => { + expect(followupFormError([{ ...valid, followup_chore_id: '' }])) + .toEqual({ key: 'chores:followups.errorChore', n: 1 }); + }); + + it('a row with nobody to give it to names the row', () => { + expect(followupFormError([{ ...valid, user_ids: [] }])) + .toEqual({ key: 'chores:followups.errorPeople', n: 1 }); + }); + + it('a delay of zero or over a week is not a delay', () => { + expect(followupFormError([{ ...valid, amount: 0 }])) + .toEqual({ key: 'chores:followups.errorDelay', n: 1 }); + expect(followupFormError([{ ...valid, amount: 200, unit: 'hours' }])) + .toEqual({ key: 'chores:followups.errorDelay', n: 1 }); + }); + + it('the first bad row is the one named', () => { + expect(followupFormError([valid, { ...valid, followup_chore_id: '' }])) + .toEqual({ key: 'chores:followups.errorChore', n: 2 }); + }); +});