From 395ecaff0318f45bb0225f2f063ede0def1962c1 Mon Sep 17 00:00:00 2001 From: jherforth Date: Sat, 31 Oct 2026 10:37:27 -0400 Subject: [PATCH] feat(chores): a schedule wizard, and "any day" beyond a week The board's Add dialog keeps its picker of chips and gains a wizard beside it (Picker | Wizard; each display remembers which it last used). The wizard steps through it: how often (certain days, once in a while any day, every few days, a date each month, again a while after it's done, just once), the details, and for timings on days whether a chore not done that day goes at day's end or stays until done; then the chores, and a one-line summary before Add. Back keeps the answers. "Once a week, any day" (#362) becomes once in a period, any day: duration `any-day` with its period in `interval`: a week, two weeks, a month, three or six months (calendar quarters and halves), or a year. Each period's card is due on its last day, counts toward the daily bonus only then, and an undone period is one miss; the next period starts fresh. `weekly` is still accepted as any-day every week, and migration 38 turns existing weekly rows into any-day 1w. The schedule dialog offers the periods too. The board's week calendar draws an any-day chore by its period, and uses which schedules have a card up so a monthly chore done earlier in the month isn't shown as waiting. Co-Authored-By: Claude Opus 5.5 --- client/src/components/ChoreScheduleBoard.jsx | 140 ++++++++-- client/src/components/ChoreSchedulesTab.jsx | 65 +++-- client/src/components/ScheduleWizard.jsx | 253 ++++++++++++++++++ client/src/i18n/locales/en/chores.json | 83 +++++- client/src/i18n/locales/es/chores.json | 83 +++++- client/src/utils/choreDays.js | 10 +- client/src/utils/choreDays.test.js | 12 +- client/src/utils/choreHelpers.js | 6 +- client/src/utils/choreHelpers.test.js | 4 +- client/src/utils/choreScheduleGroups.js | 106 +++++--- client/src/utils/choreScheduleGroups.test.js | 72 +++-- docs/reference/backend-api.md | 2 +- docs/reference/features.md | 57 +++- server/index.js | 113 +++++--- server/migrations/registry.js | 1 + server/migrations/schema38-anyDayChores.js | 35 +++ server/services/anyDayChores.js | 94 +++++++ server/services/weeklyChores.js | 43 --- ...klyChores.test.js => anyDayChores.test.js} | 135 ++++++---- server/tests/anyDayChoresRules.test.js | 48 ++++ server/tests/weeklyChoresRules.test.js | 29 -- 21 files changed, 1091 insertions(+), 300 deletions(-) create mode 100644 client/src/components/ScheduleWizard.jsx create mode 100644 server/migrations/schema38-anyDayChores.js create mode 100644 server/services/anyDayChores.js delete mode 100644 server/services/weeklyChores.js rename server/tests/{weeklyChores.test.js => anyDayChores.test.js} (56%) create mode 100644 server/tests/anyDayChoresRules.test.js delete mode 100644 server/tests/weeklyChoresRules.test.js diff --git a/client/src/components/ChoreScheduleBoard.jsx b/client/src/components/ChoreScheduleBoard.jsx index ddf02356..5f551ffb 100644 --- a/client/src/components/ChoreScheduleBoard.jsx +++ b/client/src/components/ChoreScheduleBoard.jsx @@ -62,6 +62,14 @@ import { weekTiming, } from '../utils/choreScheduleGroups.js'; import { calendarRoutinesFor } from '../utils/routineSchedules.js'; +import ScheduleWizard, { WIZARD_START } from './ScheduleWizard.jsx'; + +// How the Add dialog picks a timing: the picker of chips, or the wizard. +// Remembered per display; storage may be unavailable. +const ADD_MODE_KEY = 'homeglow_choreAddMode'; +const readAddMode = () => { + try { return localStorage.getItem(ADD_MODE_KEY) === 'wizard' ? 'wizard' : 'picker'; } catch { return 'picker'; } +}; // "By person" view of Admin → Chores → Schedules: one tab per person (and // one for the unassigned bonus chores), a week calendar of what they have on @@ -217,8 +225,8 @@ export default function ChoreScheduleBoard({ return timing.dueDate ? t('chores:board.timing.oneTimeOn', { date: dateLabel(timing.dueDate) }) : t('chores:board.timing.oneTime'); case 'after': return t('chores:board.timing.againAfter', { interval: intervalText(timing.interval) }); - case 'weekly': - return t('chores:board.timing.weeklyAnyDay'); + case 'anyDay': + return t(`chores:board.timing.anyDay.${timing.period}`); default: return t('chores:board.timing.custom', { expr: timing.rest }); } @@ -248,9 +256,15 @@ export default function ChoreScheduleBoard({ .catch(() => { if (current) setHistory([]); }); return () => { current = false; }; }, [userId, dayKeys, today, schedules]); + // The sticky schedules with a card up now: an any-day chore without one + // was done earlier in its period (calendarWeek). + const openCards = useMemo( + () => new Set(schedules.filter((s) => s.parent_schedule_id && !s.crontab && s.visible).map((s) => s.parent_schedule_id)), + [schedules], + ); const week = useMemo( - () => calendarWeek({ groups, dates: dayKeys, today, rows: history, vacation }), - [groups, dayKeys, today, history, vacation], + () => calendarWeek({ groups, dates: dayKeys, today, rows: history, vacation, openCards }), + [groups, dayKeys, today, history, vacation, openCards], ); // ── Moving one chore (drag, or Move to…) ────────────────────────── @@ -313,7 +327,8 @@ export default function ChoreScheduleBoard({ }); // ── Add chores ──────────────────────────────────────────────────── - const [addDialog, setAddDialog] = useState(null); // { step, timing, picked:Set, days:[], date } + // step: 'timing' → 'chores' (→ 'review' from the wizard). + const [addDialog, setAddDialog] = useState(null); // { step, timing, picked:Set, days:[], date, wizard, keep, fromWizard } const openAdd = (timing = null) => setAddDialog({ step: timing ? 'chores' : 'timing', timing, @@ -324,7 +339,17 @@ export default function ChoreScheduleBoard({ panel: null, // 'monthly' | 'custom' while one of those is open dom: '1', custom: { every: 3, from: 'dates', start: todayKeyString() }, + wizard: WIZARD_START, + // From the wizard: true "stays until done", false "goes at day's end", + // null not asked (the chores keep their own setting). + keep: null, + fromWizard: false, }); + const [addMode, setAddModeState] = useState(readAddMode); + const setAddMode = (mode) => { + setAddModeState(mode); + try { localStorage.setItem(ADD_MODE_KEY, mode); } catch { /* per-display convenience only */ } + }; // A chore made with "New chore" while the dialog is open comes back ticked. const knownChoreIds = useRef(new Set(chores.map((c) => c.id))); useEffect(() => { @@ -350,9 +375,13 @@ export default function ChoreScheduleBoard({ const pickedNew = addDialog ? [...addDialog.picked].filter((id) => !mineByChore.has(id)) : []; const pickedMoves = addDialog ? [...addDialog.picked].filter((id) => mineByChore.has(id)) : []; + // The wizard's answer to "if it isn't done that day", for both the chores + // added and the ones moved; the picker doesn't ask. + const keepFields = (keep) => (keep === null || keep === undefined ? {} : { duration: keep ? 'until-completed' : 'day-of' }); const saveAdd = () => { const timing = addTiming; - const fields = scheduleFieldsForTiming(timing); + const extra = keepFields(addDialog.keep); + const fields = { ...scheduleFieldsForTiming(timing), ...extra }; const moves = pickedMoves.map((id) => mineByChore.get(id)[0].s); const created = pickedNew.length; setAddDialog(null); @@ -361,7 +390,7 @@ export default function ChoreScheduleBoard({ await axios.post(`${API_BASE_URL}/api/chore-schedules`, { chore_id: choreId, user_id: userId, visible: 1, ...fields }); } for (const s of moves) { - await axios.patch(`${API_BASE_URL}/api/chore-schedules/${s.id}`, scheduleFieldsForTiming(timing, s)); + await axios.patch(`${API_BASE_URL}/api/chore-schedules/${s.id}`, { ...scheduleFieldsForTiming(timing, s), ...extra }); } }, moves.length && !created ? t('chores:board.movedCount', { count: moves.length, timing: timingLabel(timing) }) @@ -753,10 +782,16 @@ export default function ChoreScheduleBoard({ // Every common schedule is offered, even ones the person already has: // picking one of those adds to it. const others = COMMON_TIMINGS; - const pick = (timing) => setAddDialog((d) => ({ ...d, step: 'chores', timing, date: timing.kind === 'once' ? (timing.dueDate || '') : '' })); + const pick = (timing) => setAddDialog((d) => ({ ...d, step: 'chores', timing, date: timing.kind === 'once' ? (timing.dueDate || '') : '', keep: null, fromWizard: false })); + const wizardMode = addMode === 'wizard'; + // The person's own schedules are a shortcut: in the wizard, on its first step only. + const showGroups = groups.length > 0 && (!wizardMode || addDialog.wizard.step === 'how'); + const finishWizard = (timing, keep) => setAddDialog((d) => ({ + ...d, step: 'chores', timing, keep, fromWizard: true, date: timing.kind === 'once' ? (timing.dueDate || '') : '', + })); return ( - {groups.length > 0 && ( + {showGroups && ( {t('chores:board.theirTimings', { name: personName })} @@ -778,6 +813,28 @@ export default function ChoreScheduleBoard({ )} + + mode && setAddMode(mode)} + aria-label={t('chores:wizard.modeLabel')} + > + {t('chores:wizard.picker')} + {t('chores:wizard.wizard')} + + + {wizardMode ? ( + setAddDialog((d) => ({ ...d, wizard }))} + onDone={finishWizard} + timingLabel={timingLabel} + shortDays={shortDays} + /> + ) : ( + <> {others.map((tm) => ( @@ -813,9 +870,36 @@ export default function ChoreScheduleBoard({ + + )} + + ); + }; + + // The wizard's last step: what will be added, in a sentence, before Add. + const renderAddReview = () => { + const picked = choresByTitle.filter((c) => addDialog.picked.has(c.id)); + const names = picked.map((c) => `${c.icon ? `${c.icon} ` : ''}${c.title}`); + return ( + + {t('chores:wizard.stepOf', { step: wizardStepCount(), count: wizardStepCount() })} + + + {t('chores:wizard.reviewSentence', { name: personName, chores: names.join(', '), timing: timingLabel(addTiming) })} + + {addDialog.keep === true && {t('chores:wizard.reviewKeep')}} + {addDialog.keep === false && {t('chores:wizard.reviewDrop')}} + {addTiming?.kind === 'anyDay' && {t('chores:wizard.anyDayNote')}} + + {pickedMoves.length > 0 && ( + {t('chores:wizard.reviewMoves', { count: pickedMoves.length })} + )} ); }; + // How many steps the wizard has for the answers given: how, details, + // (if missed), chores, review. + const wizardStepCount = () => (['days', 'everyN', 'monthly'].includes(addDialog?.wizard?.how) ? 5 : 4); // Every day / Weekdays / Weekends buttons that fill the day toggles. const quickDays = (onPick) => ( @@ -918,8 +1002,13 @@ export default function ChoreScheduleBoard({ }); return ( - {addDialog.timing.kind === 'weekly' && {t('chores:board.weeklyHelp')}} - {addDialog.timing.kind === 'once' && ( + {addDialog.fromWizard && ( + + {t('chores:wizard.stepOf', { step: wizardStepCount() - 1, count: wizardStepCount() })} · {t('chores:wizard.choresTitle')} + + )} + {addDialog.timing.kind === 'anyDay' && !addDialog.fromWizard && {t('chores:wizard.anyDayNote')}} + {addDialog.timing.kind === 'once' && !addDialog.fromWizard && ( setGroupMenu(null)}> {/* A group of routines only has nothing these can change; a once-a-week group has no days to change. */} - { const g = groupMenu.group; setGroupMenu(null); openEditDays(g); }} disabled={['custom', 'weekly'].includes(groupMenu?.group.timing.kind) || menuEmpty}> + { const g = groupMenu.group; setGroupMenu(null); openEditDays(g); }} disabled={['custom', 'anyDay'].includes(groupMenu?.group.timing.kind) || menuEmpty}> {t('chores:board.changeDays')} { const g = groupMenu.group; setGroupMenu(null); setCopyDialog({ group: g, targets: [] }); }} disabled={menuEmpty}> @@ -1110,21 +1199,36 @@ export default function ChoreScheduleBoard({ {/* Add chores */} setAddDialog(null)} maxWidth="sm" fullWidth fullScreen={isMobile}> - {addDialog?.step === 'chores' - ? t('chores:board.addToTitle', { timing: timingLabel(addTiming), name: personName }) - : t('chores:board.addTitle', { name: personName })} + {addDialog?.step === 'review' + ? t('chores:wizard.reviewTitle') + : addDialog?.step === 'chores' + ? t('chores:board.addToTitle', { timing: timingLabel(addTiming), name: personName }) + : t('chores:board.addTitle', { name: personName })} - {addDialog && (addDialog.step === 'timing' ? renderAddTimingStep() : renderAddChoresStep())} + {addDialog && (addDialog.step === 'timing' + ? renderAddTimingStep() + : addDialog.step === 'review' ? renderAddReview() : renderAddChoresStep())} {addDialog?.step === 'chores' && ( - + )} + {addDialog?.step === 'review' && ( + )} - {addDialog?.step === 'chores' && ( + {addDialog?.step === 'chores' && addDialog.fromWizard && ( + + )} + {(addDialog?.step === 'review' || (addDialog?.step === 'chores' && !addDialog.fromWizard)) && (