diff --git a/client/src/components/ChoreSchedulesTab.jsx b/client/src/components/ChoreSchedulesTab.jsx index d5dff4ed..64dd4fdf 100644 --- a/client/src/components/ChoreSchedulesTab.jsx +++ b/client/src/components/ChoreSchedulesTab.jsx @@ -230,6 +230,28 @@ const defaultScheduleForm = { 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, +}); + // A header cell that re-sorts its table. `sortDirection` on the cell is what // puts `aria-sort` on the , so the state is announced without a second // visually-hidden copy of it. @@ -261,6 +283,9 @@ export default function ChoreSchedulesTab({ setSaveMessage }) { const [schedules, setSchedules] = useState([]); const [chores, setChores] = useState([]); const [users, setUsers] = useState([]); + // 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); @@ -484,6 +509,8 @@ export default function ChoreSchedulesTab({ setSaveMessage }) { const openCreateChore = () => { setEditingChore(null); setChoreForm(defaultChoreForm); + setFollowupRows([]); + setFollowupError(''); setChoreDialogOpen(true); }; @@ -495,24 +522,66 @@ export default function ChoreSchedulesTab({ setSaveMessage }) { 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 ''; }; const handleSaveChore = async () => { if (!choreForm.title.trim()) return; + const formError = followupFormError(); + if (formError) { setFollowupError(formError); return; } setSavingChore(true); + let choreId = editingChore?.id; try { if (editingChore) { await axios.patch(`${API_BASE_URL}/api/chores/${editingChore.id}`, choreForm); - showMessage('success', 'Chore updated.'); } else { - await axios.post(`${API_BASE_URL}/api/chores`, choreForm); - showMessage('success', 'Chore created.'); + const res = await axios.post(`${API_BASE_URL}/api/chores`, choreForm); + 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, ...choreForm }); } + } 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(); } catch (err) { - showMessage('error', err.response?.data?.error || 'Failed to save chore.'); + // 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); } @@ -669,6 +738,13 @@ export default function ChoreSchedulesTab({ setSaveMessage }) { {c.icon && {c.icon}} {c.title} + {c.followups?.length > 0 && ( + + {c.followups.map((f) => ( + + ))} + + )} - {s.title} + + {s.title} + {s.triggered_on && ( + + )} + + {s.followup_of && ( + + {t('chores:widget.followupOf', { title: s.followup_of })} + + )} {s.description && ( {s.description} )} @@ -908,6 +994,117 @@ export default function ChoreSchedulesTab({ setSaveMessage }) { value={choreForm.icon} onChange={(icon) => 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 && ( + + )} + diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index f5665428..42ecf566 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -39,7 +39,7 @@ import PinModal from './PinModal'; import { API_BASE_URL } from '../utils/apiConfig.js'; import { getDeviceApiBase } from '../utils/deviceName.js'; import { fetchPinRemembered, setPinRemembered, shouldPromptForPin } from '../utils/adminPinDevice.js'; -import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore } from '../utils/choreHelpers.js'; +import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, countsTowardDailyBonus } from '../utils/choreHelpers.js'; import { filterVisibleUsers, toggleHiddenUserId, pruneHiddenUserIds } from '../utils/choreUserVisibility.js'; import { isControlHidden } from '../utils/displayControls.js'; import { subscribePluginEvents } from '../utils/pluginEventBridge.js'; @@ -715,9 +715,10 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }; // Mirrors the server's daily-bonus rule: a user's day is complete when they - // have at least one regular (zero-clam) chore today and none are open. + // have at least one regular (zero-clam) chore today and none are open. A + // follow-up handed out today doesn't count yet (issue #241). const isUserDayComplete = (userId) => { - const regular = getUserChoresForToday(userId).filter(c => c.clam_value === 0); + const regular = getUserChoresForToday(userId).filter(c => countsTowardDailyBonus(c)); return regular.length > 0 && regular.every(c => c.completed); }; @@ -1062,6 +1063,12 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { /> )} + {schedule.followup_of && ( + // Why it appeared: handed on when someone finished another chore (#241). + + {t('chores:widget.followupOf', { title: schedule.followup_of })} + + )} {schedule.description && ( {schedule.description} @@ -1242,8 +1249,8 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { )} {visibleUsers.map(user => { const userChores = getUserChoresForToday(user.id); - const completedChores = userChores.filter(c => c.completed && c.clam_value === 0).length; - const totalRegularChores = userChores.filter(c => c.clam_value === 0).length; + const completedChores = userChores.filter(c => c.completed && countsTowardDailyBonus(c)).length; + const totalRegularChores = userChores.filter(c => countsTowardDailyBonus(c)).length; const allRegularChoresCompleted = totalRegularChores > 0 && completedChores === totalRegularChores; return ( diff --git a/client/src/i18n/locales/en/chores.json b/client/src/i18n/locales/en/chores.json index fbbe633f..90a8e8a0 100644 --- a/client/src/i18n/locales/en/chores.json +++ b/client/src/i18n/locales/en/chores.json @@ -74,7 +74,8 @@ "uncompleteAria": "Undo \"{{title}}\"", "dueOn": "Due {{date}}", "overdue": "⚠️ Overdue", - "redeemClamsFor": "Redeem clams for {{name}}" + "redeemClamsFor": "Redeem clams for {{name}}", + "followupOf": "↪ after {{title}}" }, "days": { "sunday": "Sunday", @@ -250,5 +251,26 @@ "thu": "Thu", "fri": "Fri", "sat": "Sat" + }, + "followups": { + "heading": "When it's done", + "optional": "(optional)", + "help": "Follow-ups appear on each person's list when this chore is ticked off. They count toward the daily bonus from the next day.", + "add": "Add a follow-up", + "give": "Then give", + "to": "To", + "when": "When", + "rightAway": "Right away", + "after": "After…", + "amount": "How long after", + "unit": "Minutes or hours", + "minutes": "minutes", + "hours": "hours", + "remove": "Remove this follow-up", + "chip": "Follow-up", + "errorChore": "Follow-up {{n}}: choose a chore.", + "errorPeople": "Follow-up {{n}}: choose at least one person.", + "errorDelay": "Follow-up {{n}}: the delay must be between 1 minute and 7 days.", + "errorSave": "The chore was saved, but its follow-ups could not be." } } diff --git a/client/src/i18n/locales/es/chores.json b/client/src/i18n/locales/es/chores.json index 07376c5c..5b51da7b 100644 --- a/client/src/i18n/locales/es/chores.json +++ b/client/src/i18n/locales/es/chores.json @@ -74,7 +74,8 @@ "uncompleteAria": "Deshacer «{{title}}»", "dueOn": "Para el {{date}}", "overdue": "⚠️ Atrasada", - "redeemClamsFor": "Canjear almejas de {{name}}" + "redeemClamsFor": "Canjear almejas de {{name}}", + "followupOf": "↪ después de {{title}}" }, "days": { "sunday": "Domingo", @@ -250,5 +251,26 @@ "thu": "jue", "fri": "vie", "sat": "sáb" + }, + "followups": { + "heading": "Cuando esté hecha", + "optional": "(opcional)", + "help": "Las tareas siguientes aparecen en la lista de cada persona cuando se marca esta tarea. Cuentan para el bono diario a partir del día siguiente.", + "add": "Añadir una tarea siguiente", + "give": "Después, dar", + "to": "A", + "when": "Cuándo", + "rightAway": "Enseguida", + "after": "Después de…", + "amount": "Cuánto después", + "unit": "Minutos u horas", + "minutes": "minutos", + "hours": "horas", + "remove": "Quitar esta tarea siguiente", + "chip": "Tarea siguiente", + "errorChore": "Tarea siguiente {{n}}: elige una tarea.", + "errorPeople": "Tarea siguiente {{n}}: elige al menos una persona.", + "errorDelay": "Tarea siguiente {{n}}: la espera debe estar entre 1 minuto y 7 días.", + "errorSave": "La tarea se guardó, pero sus tareas siguientes no." } } diff --git a/client/src/utils/choreHelpers.js b/client/src/utils/choreHelpers.js index a8c74284..5856d1c5 100644 --- a/client/src/utils/choreHelpers.js +++ b/client/src/utils/choreHelpers.js @@ -46,6 +46,17 @@ export function shouldShowChoreToday(schedule) { * a claim. Due-ness comes from shouldShowChoreToday, the same predicate the * widget renders with, so the gate and the list can never disagree. */ +/** + * Whether a chore counts toward the daily bonus today: a regular (zero-clam) + * chore, unless it is a follow-up handed out today. A follow-up can arrive at + * 9 pm, so it counts from the next day (issue #241). Mirrors the server's + * getTodaysRegularChoresForUser, so the widget and the bonus agree. + */ +export function countsTowardDailyBonus(schedule, today = getTodayDateString()) { + if ((schedule.clam_value || 0) !== 0) return false; + return !(schedule.triggered_on && schedule.triggered_on === today); +} + export function hasOutstandingBonusChore(schedules, history, userId, today) { return schedules .filter((schedule) => schedule.user_id === userId diff --git a/client/src/utils/choreHelpers.test.js b/client/src/utils/choreHelpers.test.js index b2b735ec..a424181d 100644 --- a/client/src/utils/choreHelpers.test.js +++ b/client/src/utils/choreHelpers.test.js @@ -4,7 +4,27 @@ vi.mock('./timezone.js', () => ({ getServerTimezoneSync: () => 'UTC', })); -import { shouldShowChoreToday, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, compareByKey } from './choreHelpers.js'; +import { shouldShowChoreToday, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, compareByKey, countsTowardDailyBonus } from './choreHelpers.js'; + +// Issue #241: a follow-up counts toward the daily bonus from the day after it +// was handed out, as the server's getTodaysRegularChoresForUser does. +describe('countsTowardDailyBonus', () => { + const today = '2026-10-08'; + + it('counts a regular chore and not a bonus chore', () => { + expect(countsTowardDailyBonus({ clam_value: 0 }, today)).toBe(true); + expect(countsTowardDailyBonus({ clam_value: 5 }, today)).toBe(false); + }); + + it('does not count a follow-up on the day it appeared, then does', () => { + expect(countsTowardDailyBonus({ clam_value: 0, triggered_on: today }, today)).toBe(false); + expect(countsTowardDailyBonus({ clam_value: 0, triggered_on: '2026-10-07' }, today)).toBe(true); + }); + + it('never counts a bonus follow-up, whatever the day', () => { + expect(countsTowardDailyBonus({ clam_value: 3, triggered_on: '2026-10-07' }, today)).toBe(false); + }); +}); describe('choreHelpers utilities', () => { let consoleErrorSpy; diff --git a/docs/reference/backend-api.md b/docs/reference/backend-api.md index 2ea4cc57..75d8e99f 100644 --- a/docs/reference/backend-api.md +++ b/docs/reference/backend-api.md @@ -176,8 +176,9 @@ static route. ### Chores, schedules & history | Method | Path | Purpose | | --- | --- | --- | -| GET/POST | `/api/chores` | List / create chore definitions. | -| PATCH/DELETE | `/api/chores/:id` | Update / delete a chore. | +| GET/POST | `/api/chores` | List / create chore definitions. Each listed chore carries `followups: [{ id, followup_chore_id, title }]`. | +| PATCH/DELETE | `/api/chores/:id` | Update / delete a chore. Deleting removes its follow-up rules and any rule that names it. | +| GET/PUT | `/api/chores/:id/followups` | A chore's follow-ups (issue #241). PUT replaces them with `{ followups: [{ followup_chore_id, user_ids, delay_minutes }] }`: at most 3, not the chore itself, no loops, at least one person, delay 0–10080 minutes. | | GET/POST | `/api/chore-schedules` | List (filter by `user_id`, `visible`, `usage`, `chore_id`) / create. Accepts `due_time` (`HH:MM`), `sound`, `sound_enabled`, `reminder_interval_minutes` for due-time sounds, and `due_date` (`YYYY-MM-DD`) for calendar deadlines. PATCH `user_id` reassigns a chore and re-checks the daily bonus for both owners. | | GET/PATCH/DELETE | `/api/chore-schedules/:id` | Single schedule CRUD. | | POST | `/api/chore-schedules/bulk` | Bulk create schedules. | @@ -186,8 +187,8 @@ static route. | GET | `/api/chore-history/summary/:userId` | Summary/aggregate for a user. | | GET | `/api/chore-history/recent` | Recent (last 7 days) completions. | | DELETE | `/api/chore-history/:id` | Delete a history entry. | -| POST | `/api/chores/complete` | Mark a chore complete (awards clams / daily bonus). | -| POST | `/api/chores/uncomplete` | Undo a completion. | +| POST | `/api/chores/complete` | Mark a chore complete (awards clams / daily bonus). A completion dated today hands out the chore's follow-ups; the response lists them in `followups_created`. | +| POST | `/api/chores/uncomplete` | Undo a completion, and remove the follow-ups it handed out that nobody has done (`followups_removed`). | ### Users & clams | Method | Path | Purpose | diff --git a/docs/reference/features.md b/docs/reference/features.md index 3b69ca2a..9945998e 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -154,6 +154,41 @@ done" bonus for **both** the previous and new owner and never removes points. **Code:** reassign UI in `ChoreWidget.jsx`; `PATCH /api/chore-schedules/:id` + `awardDailyRegularBonusIfDue` in `server/index.js`. +### Follow-up chores (issue #241) + +Some chores can only start once another is finished: the dishwasher has run, so +*Unload the dishwasher*. A chore can have **up to 3 follow-ups**, set in its edit dialog +under **When it's done**: *"Then give **Unload the dishwasher** to **Liam**, **after 2 +hours**."* + +- **Trigger:** completing the chore **today**, from any schedule, by anyone, including + plugins such as Routines. Each person named gets an ordinary one-time schedule of the + follow-up chore. Back-dated completions in the Admin Panel don't hand work on. +- **Delay:** right away, or after N minutes or hours (up to 7 days). A delay is a snooze, + so the follow-up stays hidden, and out of the bonus, until it ends. Follow-ups have no + due date or time. +- **Fairness:** a follow-up doesn't count toward the daily bonus, or get logged as + missed, on the day it appeared, since it can arrive at 9 pm. From the next day it + counts like any one-time chore, and it stays until it's done. +- **No stacking:** while a person still has an open follow-up from a rule, finishing the + chore again doesn't give them a second one. +- **Undo:** unticking the trigger removes the follow-ups it handed out that nobody has + done. Ones already done are kept. +- **Chains** (A, then B, then C) work. Loops are refused when saving. +- **Deleting** either chore removes the rule. Follow-ups already handed out stay until + done. +- **Display:** + - the widget shows "↪ after Run the dishwasher" on a follow-up; + - the definitions table shows a chip per follow-up; + - the schedules table marks follow-up schedules. + +**Code:** `chore_followups` table and `chore_schedules.followup_rule_id`, +`triggered_by_schedule_id` and `triggered_on` (migration `schema27-choreFollowups`). +`createFollowupsForCompletion`, `removeOpenFollowupsFrom` and the first-day rule in +`getTodaysRegularChoresForUser` are in `server/index.js`. The dialog is in +`ChoreSchedulesTab.jsx`, and `countsTowardDailyBonus` (`utils/choreHelpers.js`) keeps the +widget in step with the server. + ### Metrics-ready history (issue #72) Every `chore_history` row carries a **`kind`** (`completion`, `daily_bonus`, diff --git a/server/index.js b/server/index.js index 451f6a92..cb910f0e 100644 --- a/server/index.js +++ b/server/index.js @@ -231,6 +231,7 @@ const schemaMigrations = [ { schemaId: 24, migrationPath: './migrations/schema24-choreIcon', }, { schemaId: 25, migrationPath: './migrations/schema25-unifyCredentialEncryption', }, { schemaId: 26, migrationPath: './migrations/schema26-keepLegacyTimezone', }, + { schemaId: 27, migrationPath: './migrations/schema27-choreFollowups', }, ]; const ALLOWED_SCHEDULE_DURATIONS = new Set(['day-of', 'until-completed', 'once-completed']); @@ -2651,13 +2652,64 @@ function normalizeChoreIcon(icon) { fastify.get('/api/chores', async (request, reply) => { try { const rows = db.prepare('SELECT * FROM chores').all(); - return rows; + // Each chore's follow-ups by title, for the chips in the Admin Panel (#241). + const followups = db.prepare(` + SELECT f.id, f.chore_id, f.followup_chore_id, c.title + FROM chore_followups f JOIN chores c ON c.id = f.followup_chore_id + ORDER BY f.sort_order, f.id + `).all(); + return rows.map((row) => ({ + ...row, + followups: followups + .filter((f) => f.chore_id === row.id) + .map((f) => ({ id: f.id, followup_chore_id: f.followup_chore_id, title: f.title })), + })); } catch (error) { console.error('Error fetching chores:', error); reply.status(500).send({ error: 'Failed to fetch chores' }); } }); +// A chore's follow-ups (issue #241): what to give to whom when it is done. +fastify.get('/api/chores/:id/followups', async (request, reply) => { + const choreId = Number(request.params.id); + try { + if (!db.prepare('SELECT 1 FROM chores WHERE id = ?').get(choreId)) { + return reply.status(404).send({ error: 'Chore not found' }); + } + return listChoreFollowups(choreId); + } catch (error) { + console.error('Error fetching chore follow-ups:', error); + reply.status(500).send({ error: 'Failed to fetch follow-ups' }); + } +}); + +// Replaces the chore's follow-ups with the list sent: { followups: [{ +// followup_chore_id, user_ids: [..], delay_minutes }] }. An empty list removes +// them all. Follow-ups already handed out are untouched. +fastify.put('/api/chores/:id/followups', async (request, reply) => { + const choreId = Number(request.params.id); + try { + if (!db.prepare('SELECT 1 FROM chores WHERE id = ?').get(choreId)) { + return reply.status(404).send({ error: 'Chore not found' }); + } + const checked = validateChoreFollowups(choreId, request.body?.followups); + if (checked.error) return reply.status(400).send({ error: checked.error }); + + const insert = db.prepare('INSERT INTO chore_followups (chore_id, followup_chore_id, user_ids, delay_minutes, sort_order) VALUES (?, ?, ?, ?, ?)'); + db.transaction(() => { + db.prepare('DELETE FROM chore_followups WHERE chore_id = ?').run(choreId); + checked.rules.forEach((rule, index) => { + insert.run(choreId, rule.followupChoreId, JSON.stringify(rule.userIds), rule.delay, index); + }); + })(); + return listChoreFollowups(choreId); + } catch (error) { + console.error('Error saving chore follow-ups:', error); + reply.status(500).send({ error: 'Failed to save follow-ups' }); + } +}); + fastify.post('/api/chores', async (request, reply) => { const { title, description, clam_value, icon } = request.body; try { @@ -2708,7 +2760,11 @@ fastify.delete('/api/chores/:id', async (request, reply) => { fastify.get('/api/chore-schedules', async (request, reply) => { try { const { user_id, visible, usage, chore_id } = request.query; - let query = 'SELECT cs.*, c.title, c.description, c.clam_value, c.icon FROM chore_schedules cs JOIN chores c ON cs.chore_id = c.id'; + // followup_of: the chore whose completion handed this one on, for the + // "after Run the dishwasher" caption in the widget (issue #241). + let query = 'SELECT cs.*, c.title, c.description, c.clam_value, c.icon, tc.title AS followup_of ' + + 'FROM chore_schedules cs JOIN chores c ON cs.chore_id = c.id ' + + 'LEFT JOIN chore_followups fr ON fr.id = cs.followup_rule_id LEFT JOIN chores tc ON tc.id = fr.chore_id'; const conditions = []; const params = []; @@ -3266,6 +3322,13 @@ function getTodaysRegularChoresForUser(userId, dateStr, referenceNow = new Date( // this works for past dates too. const todaysChores = []; for (const schedule of regularChores) { + // A follow-up doesn't count on the day it appeared: it can arrive at 9 pm, + // too late to be fair (issue #241). From the next day it counts like any + // one-time chore. This drives both the daily bonus and missed logging. + if (schedule.triggered_on && schedule.triggered_on === dateStr) { + continue; + } + // schedules without crontab are one-time and always part of today's chores if (!schedule.crontab) { todaysChores.push(schedule); @@ -3281,6 +3344,150 @@ function getTodaysRegularChoresForUser(userId, dateStr, referenceNow = new Date( return todaysChores; } +// --- Follow-up chores (issue #241) --- +// "When Run the dishwasher is done, give Unload the dishwasher to Liam, after +// 2 hours." A rule belongs to a chore. Completing that chore today gives each +// named person an ordinary one-time schedule of the follow-up chore, so the +// widget, clams, transfer, snooze, history and nightly cleanup all apply +// unchanged. A delay is a snooze: hidden, and out of the bonus, until it ends. +// No due date: a follow-up has none (issue #241), so it never reads as overdue. + +const FOLLOWUP_MAX_PER_CHORE = 3; +const FOLLOWUP_MAX_DELAY_MINUTES = 7 * 24 * 60; + +function parseFollowupUserIds(raw) { + try { + const ids = JSON.parse(raw || '[]'); + return Array.isArray(ids) ? ids.filter(Number.isInteger) : []; + } catch { + return []; + } +} + +function listChoreFollowups(choreId) { + return db.prepare(` + SELECT f.id, f.followup_chore_id, f.user_ids, f.delay_minutes, c.title + FROM chore_followups f + JOIN chores c ON c.id = f.followup_chore_id + WHERE f.chore_id = ? + ORDER BY f.sort_order, f.id + `).all(choreId).map((row) => ({ + id: row.id, + followup_chore_id: row.followup_chore_id, + title: row.title, + user_ids: parseFollowupUserIds(row.user_ids), + delay_minutes: row.delay_minutes, + })); +} + +// Checks a replacement list of follow-ups for a chore. Returns { rules } ready +// to store, or { error } saying which one is wrong and why. +function validateChoreFollowups(choreId, input) { + if (!Array.isArray(input)) return { error: 'followups must be a list.' }; + if (input.length > FOLLOWUP_MAX_PER_CHORE) { + return { error: `A chore can have at most ${FOLLOWUP_MAX_PER_CHORE} follow-ups.` }; + } + const choreExists = db.prepare('SELECT 1 FROM chores WHERE id = ?'); + const userExists = db.prepare('SELECT 1 FROM users WHERE id = ?'); + const rules = []; + for (const [index, raw] of input.entries()) { + const n = index + 1; + const followupChoreId = Number(raw?.followup_chore_id); + if (!Number.isInteger(followupChoreId) || !choreExists.get(followupChoreId)) { + return { error: `Follow-up ${n}: choose a chore.` }; + } + if (followupChoreId === choreId) { + return { error: `Follow-up ${n}: a chore can't follow itself.` }; + } + if (rules.some((rule) => rule.followupChoreId === followupChoreId)) { + return { error: `Follow-up ${n}: that chore is already a follow-up.` }; + } + const userIds = []; + for (const rawId of Array.isArray(raw.user_ids) ? raw.user_ids : []) { + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0 || !userExists.get(id)) { + return { error: `Follow-up ${n}: unknown person ${JSON.stringify(rawId)}.` }; + } + if (!userIds.includes(id)) userIds.push(id); + } + if (!userIds.length) return { error: `Follow-up ${n}: choose at least one person.` }; + const delay = raw.delay_minutes === undefined || raw.delay_minutes === null || raw.delay_minutes === '' + ? 0 + : Number(raw.delay_minutes); + if (!Number.isInteger(delay) || delay < 0 || delay > FOLLOWUP_MAX_DELAY_MINUTES) { + return { error: `Follow-up ${n}: the delay must be between 0 minutes and 7 days.` }; + } + rules.push({ followupChoreId, userIds, delay }); + } + + // No loops: with these rules in place of the chore's current ones, no chain + // of follow-ups may lead back to it. Chains (A, then B, then C) are fine. + const next = new Map(); + for (const row of db.prepare('SELECT chore_id, followup_chore_id FROM chore_followups WHERE chore_id <> ?').all(choreId)) { + if (!next.has(row.chore_id)) next.set(row.chore_id, []); + next.get(row.chore_id).push(row.followup_chore_id); + } + const seen = new Set(); + const stack = rules.map((rule) => rule.followupChoreId); + while (stack.length) { + const current = stack.pop(); + if (current === choreId) return { error: 'These follow-ups would lead back to this chore.' }; + if (seen.has(current)) continue; + seen.add(current); + (next.get(current) || []).forEach((id) => stack.push(id)); + } + return { rules }; +} + +// Give each follow-up of a just-completed chore to its people. Only a +// completion dated today hands work on: back-dated catch-ups in the Admin +// Panel don't. A person who still has an open follow-up from the same rule +// gets no second one. Runs inside the completion's transaction. +function createFollowupsForCompletion(schedule, date) { + if (date !== getTodayLocalDateString()) return []; + const rules = db.prepare('SELECT id, followup_chore_id, user_ids, delay_minutes FROM chore_followups WHERE chore_id = ? ORDER BY sort_order, id').all(schedule.chore_id); + if (!rules.length) return []; + + const userExists = db.prepare('SELECT 1 FROM users WHERE id = ?'); + const openFollowup = db.prepare(` + SELECT 1 FROM chore_schedules cs + WHERE cs.followup_rule_id = ? AND cs.user_id = ? AND cs.visible = 1 + AND NOT EXISTS (SELECT 1 FROM chore_history h WHERE h.chore_schedule_id = cs.id AND h.kind = 'completion') + `); + const insert = db.prepare(` + INSERT INTO chore_schedules + (chore_id, user_id, crontab, duration, visible, snoozed_until, followup_rule_id, triggered_by_schedule_id, triggered_on) + VALUES (?, ?, NULL, 'day-of', 1, ?, ?, ?, ?) + `); + + const created = []; + for (const rule of rules) { + const snoozedUntil = rule.delay_minutes > 0 + ? new Date(Date.now() + rule.delay_minutes * 60 * 1000).toISOString() + : null; + for (const userId of parseFollowupUserIds(rule.user_ids)) { + // A person removed since the rule was saved is skipped, not an error. + if (!userExists.get(userId) || openFollowup.get(rule.id, userId)) continue; + const info = insert.run(rule.followup_chore_id, userId, snoozedUntil, rule.id, schedule.id, date); + created.push({ schedule_id: info.lastInsertRowid, user_id: userId, chore_id: rule.followup_chore_id }); + } + } + return created; +} + +// Undoing a completion takes back the follow-ups it handed on that nobody has +// done yet. Ones already done are kept: that work really happened. +function removeOpenFollowupsFrom(scheduleId, date) { + return db.prepare(` + DELETE FROM chore_schedules + WHERE triggered_by_schedule_id = ? AND triggered_on = ? + AND NOT EXISTS ( + SELECT 1 FROM chore_history h + WHERE h.chore_schedule_id = chore_schedules.id AND h.kind = 'completion' + ) + `).run(scheduleId, date).changes; +} + // Awards the daily "all regular chores completed" bonus to a user if every regular // chore due today is completed and the bonus hasn't already been recorded. // Never removes points; safe to call any time a user's chore set changes (e.g. reassignment). @@ -3369,31 +3576,38 @@ fastify.post('/api/chores/complete', async (request, reply) => { return reply.status(409).send({ error: 'Chore already completed for this date' }); } - db.prepare("DELETE FROM chore_history WHERE chore_schedule_id = ? AND user_id = ? AND date = ? AND kind = 'missed'").run(chore_schedule_id, user_id, date); - db.prepare("INSERT INTO chore_history (user_id, chore_schedule_id, date, clam_value, title, kind) VALUES (?, ?, ?, ?, ?, 'completion')").run(user_id, chore_schedule_id, date, schedule.clam_value, schedule.title); + // One transaction: the completion and the follow-ups it hands on are + // written together or not at all (issue #241). + const recordCompletion = db.transaction(() => { + db.prepare("DELETE FROM chore_history WHERE chore_schedule_id = ? AND user_id = ? AND date = ? AND kind = 'missed'").run(chore_schedule_id, user_id, date); + db.prepare("INSERT INTO chore_history (user_id, chore_schedule_id, date, clam_value, title, kind) VALUES (?, ?, ?, ?, ?, 'completion')").run(user_id, chore_schedule_id, date, schedule.clam_value, schedule.title); - // Pay out a pending transfer bonus (attached by the parent when moving - // this chore to a kid whose day was already complete) and clear it so it - // pays only once. - if (schedule.transfer_bonus_clams > 0) { - db.prepare("INSERT INTO chore_history (user_id, chore_schedule_id, date, clam_value, title, kind) VALUES (?, ?, ?, ?, ?, 'transfer_bonus')").run(user_id, chore_schedule_id, date, schedule.transfer_bonus_clams, 'Transfer bonus'); - db.prepare('UPDATE chore_schedules SET transfer_bonus_clams = 0 WHERE id = ?').run(chore_schedule_id); - } + // Pay out a pending transfer bonus (attached by the parent when moving + // this chore to a kid whose day was already complete) and clear it so it + // pays only once. + if (schedule.transfer_bonus_clams > 0) { + db.prepare("INSERT INTO chore_history (user_id, chore_schedule_id, date, clam_value, title, kind) VALUES (?, ?, ?, ?, ?, 'transfer_bonus')").run(user_id, chore_schedule_id, date, schedule.transfer_bonus_clams, 'Transfer bonus'); + db.prepare('UPDATE chore_schedules SET transfer_bonus_clams = 0 WHERE id = ?').run(chore_schedule_id); + } - if (schedule.parent_schedule_id) { - const parentSchedule = db.prepare('SELECT id, duration, interval FROM chore_schedules WHERE id = ?').get(schedule.parent_schedule_id); - if (parentSchedule && parentSchedule.duration === 'once-completed') { - const completionDate = parseDateOnlyToLocalDate(date); - const nextDueDate = completionDate ? addIntervalToDate(completionDate, parentSchedule.interval) : null; - const nextCrontab = buildDateCrontab(nextDueDate); + if (schedule.parent_schedule_id) { + const parentSchedule = db.prepare('SELECT id, duration, interval FROM chore_schedules WHERE id = ?').get(schedule.parent_schedule_id); + if (parentSchedule && parentSchedule.duration === 'once-completed') { + const completionDate = parseDateOnlyToLocalDate(date); + const nextDueDate = completionDate ? addIntervalToDate(completionDate, parentSchedule.interval) : null; + const nextCrontab = buildDateCrontab(nextDueDate); - if (nextCrontab) { - db.prepare('UPDATE chore_schedules SET crontab = ?, visible = 1 WHERE id = ?').run(nextCrontab, parentSchedule.id); - } else { - console.warn(`Could not reschedule once-completed parent schedule ${parentSchedule.id}; invalid interval: ${parentSchedule.interval}`); + if (nextCrontab) { + db.prepare('UPDATE chore_schedules SET crontab = ?, visible = 1 WHERE id = ?').run(nextCrontab, parentSchedule.id); + } else { + console.warn(`Could not reschedule once-completed parent schedule ${parentSchedule.id}; invalid interval: ${parentSchedule.interval}`); + } } } - } + + return createFollowupsForCompletion(schedule, date); + }); + const followupsCreated = recordCompletion(); // Announce this completion before awarding the daily bonus, because the // award emits chore.allCompleted. Emitting in the other order would tell a @@ -3412,7 +3626,7 @@ fastify.post('/api/chores/complete', async (request, reply) => { // Read the total after the award so the response includes the bonus. const totalResult = db.prepare('SELECT COALESCE(SUM(clam_value), 0) as total FROM chore_history WHERE user_id = ?').get(user_id); - return { success: true, clam_total: totalResult.total }; + return { success: true, clam_total: totalResult.total, followups_created: followupsCreated }; } catch (error) { console.error('Error completing chore:', error); reply.status(500).send({ error: 'Failed to complete chore' }); @@ -3448,6 +3662,10 @@ fastify.post('/api/chores/uncomplete', async (request, reply) => { revokeDailyRegularBonus(user_id, date); } + // Take back the follow-ups this completion handed on that nobody has done + // yet: ticked by mistake, unticked, and Liam's "Unload" goes away (#241). + const followupsRemoved = removeOpenFollowupsFrom(chore_schedule_id, date); + const totalResult = db.prepare('SELECT COALESCE(SUM(clam_value), 0) as total FROM chore_history WHERE user_id = ?').get(user_id); // Mirror of chore.completed so plugins (and declarative reactions with a @@ -3462,7 +3680,7 @@ fastify.post('/api/chores/uncomplete', async (request, reply) => { date, }); - return { success: true, clam_total: totalResult.total }; + return { success: true, clam_total: totalResult.total, followups_removed: followupsRemoved }; } catch (error) { console.error('Error uncompleting chore:', error); reply.status(500).send({ error: 'Failed to uncomplete chore' }); diff --git a/server/migrations/schema27-choreFollowups.js b/server/migrations/schema27-choreFollowups.js new file mode 100644 index 00000000..0602b044 --- /dev/null +++ b/server/migrations/schema27-choreFollowups.js @@ -0,0 +1,69 @@ +const context = globalThis.__HOMEGLOW_SCHEMA_MIGRATION_CONTEXT; + +if (!context || !context.db) { + throw new Error('Schema migration context is missing for migration'); +} + +const { db, schemaIdKey, targetSchemaId } = context; + +try { + console.log(`=== Starting chore follow-ups schema migration to version ${targetSchemaId} ===`); + + db.exec('BEGIN'); + try { + // Follow-up chores (issue #241): "when Run the dishwasher is done, + // give Unload the dishwasher to Liam". A rule belongs to the chore, + // not a schedule, so a chore with several schedules is set up once. + // + // Both chore references cascade: deleting either chore removes the + // rule, so a chore used as a follow-up can still be deleted (with + // foreign keys enforced, a plain reference would block it). + db.exec(` + CREATE TABLE IF NOT EXISTS chore_followups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE, + followup_chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE, + user_ids TEXT NOT NULL DEFAULT '[]', + delay_minutes INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_chore_followups_chore ON chore_followups(chore_id)'); + + // A follow-up arrives as an ordinary one-time schedule. These say + // where it came from: + // - followup_rule_id: the rule that made it; + // - triggered_by_schedule_id + triggered_on: the completion that + // made it, so undoing that completion can remove it. Not a foreign + // key: a completed one-time trigger is pruned nightly, and undo + // must still find its follow-ups by these values. + // triggered_on is also what keeps a follow-up out of the daily bonus + // on the day it appeared. + const scheduleColumns = db.prepare('PRAGMA table_info(chore_schedules)').all().map((c) => c.name); + if (!scheduleColumns.includes('followup_rule_id')) { + db.exec('ALTER TABLE chore_schedules ADD COLUMN followup_rule_id INTEGER REFERENCES chore_followups(id) ON DELETE SET NULL'); + } + if (!scheduleColumns.includes('triggered_by_schedule_id')) { + db.exec('ALTER TABLE chore_schedules ADD COLUMN triggered_by_schedule_id INTEGER'); + } + if (!scheduleColumns.includes('triggered_on')) { + db.exec('ALTER TABLE chore_schedules ADD COLUMN triggered_on TEXT'); + } + + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run( + schemaIdKey, + String(targetSchemaId) + ); + + db.exec('COMMIT'); + console.log(`=== Chore follow-ups schema migration completed (version ${targetSchemaId}) ===`); + } catch (migrationError) { + db.exec('ROLLBACK'); + throw migrationError; + } +} catch (error) { + console.error('=== Chore follow-ups schema migration failed ==='); + console.error('Error:', error); + throw error; +} diff --git a/server/tests/choreFollowups.test.js b/server/tests/choreFollowups.test.js new file mode 100644 index 00000000..d37cc213 --- /dev/null +++ b/server/tests/choreFollowups.test.js @@ -0,0 +1,340 @@ +// Follow-up chores (issue #241): "when Run the dishwasher is done, give +// Unload the dishwasher to Liam". Rules live on the chore; completing it today +// gives each named person an ordinary one-time schedule, which doesn't count +// toward the daily bonus on the day it appeared. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const Database = require('better-sqlite3'); + +const serverDir = path.resolve(__dirname, '..'); +const tmpDir = path.resolve(__dirname, '.tmp'); +const testDbPath = path.join(tmpDir, `chore-followups-${process.pid}-${Date.now()}.db`); +const keepTestArtifacts = process.env.HOMEGLOW_TEST_KEEP_ARTIFACTS === '1'; +const port = 8700 + Math.floor(Math.random() * 250); +const baseUrl = `http://127.0.0.1:${port}`; + +// The server runs in UTC, so this is its "today". +const today = new Date().toISOString().slice(0, 10); +const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + +let serverProcess; +let serverLogs = ''; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServerReady(timeoutMs = 30000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/api/test`); + if (response.ok) return; + } catch { + // Server is still starting. + } + await delay(250); + } + throw new Error(`Server did not become ready within ${timeoutMs}ms. Logs:\n${serverLogs}`); +} + +async function api(pathname, options = {}) { + const headers = { ...(options.headers || {}) }; + if (options.body !== undefined && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + const response = await fetch(`${baseUrl}${pathname}`, { ...options, headers }); + const text = await response.text(); + let body; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: response.status, body }; +} + +const post = (pathname, body) => api(pathname, { method: 'POST', body: JSON.stringify(body) }); +const put = (pathname, body) => api(pathname, { method: 'PUT', body: JSON.stringify(body) }); + +const newUser = async (username) => (await post('/api/users', { username })).body.id; +const newChore = async (title, clamValue = 0) => (await post('/api/chores', { title, description: '', clam_value: clamValue })).body.id; +// A daily schedule, so it is due today and every day. +const newSchedule = async (choreId, userId) => (await post('/api/chore-schedules', { + chore_id: choreId, user_id: userId, crontab: '0 0 * * *', duration: 'day-of', +})).body.id; +const complete = (scheduleId, userId, date = today) => post('/api/chores/complete', { chore_schedule_id: scheduleId, user_id: userId, date }); +const uncomplete = (scheduleId, userId, date = today) => post('/api/chores/uncomplete', { chore_schedule_id: scheduleId, user_id: userId, date }); +const setFollowups = (choreId, followups) => put(`/api/chores/${choreId}/followups`, { followups }); +const schedulesOf = async (choreId) => (await api(`/api/chore-schedules?chore_id=${choreId}`)).body; +const bonusToday = async (userId) => (await api(`/api/chore-history?user_id=${userId}&date_from=${today}`)).body + .some((row) => row.kind === 'daily_bonus' && row.user_id === userId && row.date === today); + +// Reach into the database for what the API can't do on purpose: make a +// follow-up look as if it had been handed out on an earlier day. +function setTriggeredOn(scheduleId, date) { + const db = new Database(testDbPath); + try { + db.prepare('UPDATE chore_schedules SET triggered_on = ? WHERE id = ?').run(date, scheduleId); + } finally { + db.close(); + } +} + +test.before(async () => { + fs.mkdirSync(tmpDir, { recursive: true }); + serverProcess = spawn('node', ['index.js'], { + cwd: serverDir, + env: { + ...process.env, + PORT: String(port), + DB_PATH: testDbPath, + TZ: 'UTC', + HOMEGLOW_DISABLE_BACKGROUND_JOBS: '1', + HOMEGLOW_DISABLE_CALENDAR_SYNC: '1', + HOMEGLOW_DISABLE_UPDATE_CHECK: '1', + ENCRYPTION_KEY: Buffer.alloc(32, 3).toString('base64'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + serverProcess.stdout.on('data', (chunk) => { serverLogs += chunk.toString(); }); + serverProcess.stderr.on('data', (chunk) => { serverLogs += chunk.toString(); }); + await waitForServerReady(); +}); + +test.after(async () => { + if (serverProcess) { + serverProcess.kill(); + await delay(300); + } + if (!keepTestArtifacts) { + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(testDbPath + suffix); } catch { /* already gone */ } + } + } +}); + +// --- rules --- + +test('follow-ups save, list, and show on the chores list', async () => { + const liam = await newUser('Rules Liam'); + const ava = await newUser('Rules Ava'); + const dishwasher = await newChore('Run the dishwasher'); + const unload = await newChore('Unload the dishwasher'); + + const saved = await setFollowups(dishwasher, [{ followup_chore_id: unload, user_ids: [liam, ava, liam], delay_minutes: 120 }]); + assert.equal(saved.status, 200, JSON.stringify(saved.body)); + assert.deepEqual(saved.body.map((f) => [f.followup_chore_id, f.title, f.user_ids, f.delay_minutes]), + [[unload, 'Unload the dishwasher', [liam, ava], 120]]); + + assert.deepEqual((await api(`/api/chores/${dishwasher}/followups`)).body, saved.body); + const listed = (await api('/api/chores')).body.find((c) => c.id === dishwasher); + assert.deepEqual(listed.followups.map((f) => f.title), ['Unload the dishwasher']); + + // An empty list removes them. + assert.deepEqual((await setFollowups(dishwasher, [])).body, []); +}); + +test('a bad follow-up is refused, saying which one and why', async () => { + const liam = await newUser('Valid Liam'); + const a = await newChore('Valid A'); + const b = await newChore('Valid B'); + const c = await newChore('Valid C'); + const d = await newChore('Valid D'); + const e = await newChore('Valid E'); + const rule = (id, extra = {}) => ({ followup_chore_id: id, user_ids: [liam], ...extra }); + + const cases = [ + [[rule(a)], /can't follow itself/], + [[rule(b), rule(b)], /already a follow-up/], + [[rule(b), rule(c), rule(d), rule(e)], /at most 3/], + [[rule(999999)], /choose a chore/], + [[{ followup_chore_id: b, user_ids: [] }], /at least one person/], + [[{ followup_chore_id: b, user_ids: [999999] }], /unknown person/], + [[rule(b, { delay_minutes: -5 })], /between 0 minutes and 7 days/], + [[rule(b, { delay_minutes: 7 * 24 * 60 + 1 })], /between 0 minutes and 7 days/], + ]; + for (const [followups, pattern] of cases) { + const res = await setFollowups(a, followups); + assert.equal(res.status, 400, JSON.stringify(followups)); + assert.match(res.body.error, pattern); + } + + // Loops are refused; chains are fine. + assert.equal((await setFollowups(a, [rule(b)])).status, 200); + assert.equal((await setFollowups(b, [rule(c)])).status, 200); + const loop = await setFollowups(c, [rule(a)]); + assert.equal(loop.status, 400); + assert.match(loop.body.error, /lead back/); + assert.equal((await api('/api/chores/999999/followups')).status, 404); +}); + +// --- completion --- + +test('completing the chore gives each person the follow-up, once', async () => { + const mum = await newUser('Mum'); + const liam = await newUser('Hand Liam'); + const ava = await newUser('Hand Ava'); + const dishwasher = await newChore('Hand dishwasher'); + const unload = await newChore('Hand unload'); + await setFollowups(dishwasher, [{ followup_chore_id: unload, user_ids: [liam, ava], delay_minutes: 120 }]); + const run = await newSchedule(dishwasher, mum); + + const before = Date.now(); + const done = await complete(run, mum); + assert.equal(done.status, 200); + assert.deepEqual(done.body.followups_created.map((f) => f.user_id).sort(), [liam, ava].sort()); + + const given = await schedulesOf(unload); + assert.equal(given.length, 2); + for (const s of given) { + assert.equal(s.crontab, null); + assert.equal(s.visible, 1); + // No due date, so it never shows as overdue. + assert.equal(s.due_date, null); + assert.equal(s.triggered_on, today); + assert.equal(s.triggered_by_schedule_id, run); + assert.equal(s.followup_of, 'Hand dishwasher'); + // The delay is a snooze: hidden until two hours from now. + const wakes = new Date(s.snoozed_until).getTime(); + assert.ok(wakes >= before + 119 * 60000 && wakes <= Date.now() + 121 * 60000, s.snoozed_until); + } + + // Done again (undo, redo) while those are still open: nothing stacks. + await uncomplete(run, mum); + await complete(run, mum); + assert.equal((await schedulesOf(unload)).length, 2); +}); + +test('only a completion dated today hands work on', async () => { + const mum = await newUser('Past Mum'); + const liam = await newUser('Past Liam'); + const laundry = await newChore('Past laundry'); + const putAway = await newChore('Past put away'); + await setFollowups(laundry, [{ followup_chore_id: putAway, user_ids: [liam] }]); + const sched = await newSchedule(laundry, mum); + + const res = await complete(sched, mum, daysAgo(3)); + assert.equal(res.status, 200); + assert.deepEqual(res.body.followups_created, []); + assert.deepEqual(await schedulesOf(putAway), []); +}); + +test('undo takes back the follow-ups nobody has done, and keeps the ones done', async () => { + const mum = await newUser('Undo Mum'); + const liam = await newUser('Undo Liam'); + const ava = await newUser('Undo Ava'); + const fold = await newChore('Undo fold'); + const putAway = await newChore('Undo put away'); + await setFollowups(fold, [{ followup_chore_id: putAway, user_ids: [liam, ava] }]); + const sched = await newSchedule(fold, mum); + + await complete(sched, mum); + const given = await schedulesOf(putAway); + const liams = given.find((s) => s.user_id === liam); + assert.equal((await complete(liams.id, liam)).status, 200); + + const undone = await uncomplete(sched, mum); + assert.equal(undone.status, 200); + assert.equal(undone.body.followups_removed, 1); + assert.deepEqual((await schedulesOf(putAway)).map((s) => s.user_id), [liam]); +}); + +test('a chain hands on step by step', async () => { + const mum = await newUser('Chain Mum'); + const liam = await newUser('Chain Liam'); + const ava = await newUser('Chain Ava'); + const wash = await newChore('Chain wash'); + const dry = await newChore('Chain dry'); + const fold = await newChore('Chain fold'); + await setFollowups(wash, [{ followup_chore_id: dry, user_ids: [liam] }]); + await setFollowups(dry, [{ followup_chore_id: fold, user_ids: [ava] }]); + + await complete(await newSchedule(wash, mum), mum); + const [drySched] = await schedulesOf(dry); + assert.equal(drySched.user_id, liam); + assert.deepEqual(await schedulesOf(fold), []); + + await complete(drySched.id, liam); + assert.deepEqual((await schedulesOf(fold)).map((s) => s.user_id), [ava]); +}); + +// --- fairness --- + +test('a follow-up does not count toward the daily bonus on the day it appeared, then does', async () => { + const mum = await newUser('Bonus Mum'); + const liam = await newUser('Bonus Liam'); + const bed = await newChore('Bonus make bed'); + const dishwasher = await newChore('Bonus dishwasher'); + const unload = await newChore('Bonus unload'); + await setFollowups(dishwasher, [{ followup_chore_id: unload, user_ids: [liam] }]); + const bedSched = await newSchedule(bed, liam); + + await complete(await newSchedule(dishwasher, mum), mum); + const [unloadSched] = await schedulesOf(unload); + + // Today: Liam finishes his own chore and gets the bonus, though the + // follow-up that just arrived is still open. + await complete(bedSched, liam); + assert.equal(await bonusToday(liam), true); + + // The day after it arrived, the open follow-up counts like any chore. + await uncomplete(bedSched, liam); + setTriggeredOn(unloadSched.id, daysAgo(1)); + await complete(bedSched, liam); + assert.equal(await bonusToday(liam), false); + + // Doing it then earns the bonus. + await complete(unloadSched.id, liam); + assert.equal(await bonusToday(liam), true); +}); + +test('missed-chore logging skips the day a follow-up appeared, and counts it after', async () => { + const mum = await newUser('Missed Mum'); + const liam = await newUser('Missed Liam'); + const trigger = await newChore('Missed trigger'); + const given = await newChore('Missed given'); + await setFollowups(trigger, [{ followup_chore_id: given, user_ids: [liam] }]); + await complete(await newSchedule(trigger, mum), mum); + const [followup] = await schedulesOf(given); + const missedYesterday = async () => (await api(`/api/chore-history?user_id=${liam}&date_from=${daysAgo(1)}`)).body + .some((row) => row.kind === 'missed' && row.chore_schedule_id === followup.id && row.date === daysAgo(1)); + + // Handed out yesterday: yesterday was its first day, so not missed. + setTriggeredOn(followup.id, daysAgo(1)); + await api('/api/system/backgroundTasks'); + assert.equal(await missedYesterday(), false); + + // Handed out the day before: yesterday it counted, and was missed. + setTriggeredOn(followup.id, daysAgo(2)); + await api('/api/system/backgroundTasks'); + assert.equal(await missedYesterday(), true); +}); + +// --- deleting --- + +test('deleting either chore removes the rule and does not fail', async () => { + const mum = await newUser('Delete Mum'); + const liam = await newUser('Delete Liam'); + const trigger = await newChore('Delete trigger'); + const target = await newChore('Delete target'); + const other = await newChore('Delete other'); + await setFollowups(trigger, [{ followup_chore_id: target, user_ids: [liam] }]); + + // The follow-up chore is used by a rule: deleting it works (it would fail + // on the foreign key without the cascade) and takes the rule with it. + assert.equal((await api(`/api/chores/${target}`, { method: 'DELETE' })).status, 200); + assert.deepEqual((await api(`/api/chores/${trigger}/followups`)).body, []); + + // Deleting the trigger leaves follow-ups already handed out in place. + await setFollowups(trigger, [{ followup_chore_id: other, user_ids: [liam] }]); + await complete(await newSchedule(trigger, mum), mum); + assert.equal((await schedulesOf(other)).length, 1); + assert.equal((await api(`/api/chores/${trigger}`, { method: 'DELETE' })).status, 200); + const left = await schedulesOf(other); + assert.equal(left.length, 1); + assert.equal(left[0].followup_rule_id, null); +});