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 (
+
+ );
+}
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 ─────────────────────────────────── */}
-
+ 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 (
{
/>
)}
-
+ {/* 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 */}