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 && (
+ }
+ sx={{ mt: 1 }}
+ onClick={() => { setFollowupError(''); setFollowupRows((rows) => [...rows, newFollowupRow()]); }}
+ >
+ {t('chores:followups.add')}
+
+ )}
+
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);
+});
|