diff --git a/README.md b/README.md
index 7de5d5d..4297061 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,12 @@ machine you no longer own is usually the part worth keeping.
swaps, pool scrubs. A schedule is a ticket template plus a cadence, and it opens
a real ticket when the work comes due. See [Recurring maintenance](#recurring-maintenance).
+**Shared chores** are a separate, opt-in workflow for two people using the same
+login. Chores appear on a Sunday–Saturday roster, can use the supported routine
+cadences, and keep their assignment and completion history. Existing routines
+are not converted into chores automatically, and chore assignments do not create
+person-specific alerts. See [Shared chores](docs/planning.md#shared-household-chores).
+
**The dashboard** surfaces open counts by priority, which devices have the most
unresolved work, anything overdue, and anything open that hasn't been touched
in two weeks in Next — the tasks you forgot rather than finished.
diff --git a/docs/planning.md b/docs/planning.md
index 8844743..2c16564 100644
--- a/docs/planning.md
+++ b/docs/planning.md
@@ -98,6 +98,37 @@ Old schedules retain their existing `interval_days` and lead-time behavior.
The legacy editor remains available; a 30-day interval is labeled **Every 30
days**, not monthly. Pausing or deleting a schedule preserves its task history.
+## Shared household chores
+
+Open **Chores** to set the two household display names and add chore templates.
+The app still has one shared sign-in: either person can see and edit every task.
+Chores are opt-in, use the recurrence rules above, and create new ordinary tasks
+with an assignee. Existing routines are not enrolled or converted into chores.
+Chore tasks also appear in Today, search, task lists, and the calendar, with
+their usual checklist and notification behavior. There are no person-specific
+notifications.
+
+The roster groups assignments by person for a Sunday–Saturday week in the
+configured application timezone. Each task keeps its own due date, not the end
+of the week. Known dates in the current week are assigned when the week starts;
+a new chore due later in the current week joins when created. Only one task per
+chore may remain unfinished. Later dates that depend on finishing it appear as
+conditional previews, not assignments. Missed dates during downtime are
+skipped rather than backfilled; an unfinished task remains overdue with its
+original date and owner.
+
+Weekly load includes completed assignments and open overdue carryovers. When
+loads tie, rotation considers the previous actual assignee and the preceding
+four weeks. Assignments stay put once made. If the other person did a chore,
+reassign that task before marking it done so the right person receives credit;
+the change appears in its history and affects the next rotation. Browse earlier
+weeks to see final assignments and reassignment history.
+
+Editing a chore template changes only future tasks. Edit an existing task
+separately. Archive a chore to stop future assignments without deleting its
+history or closing any unfinished task. Completed chore tasks cannot be
+reopened while a newer occurrence remains unfinished.
+
## Install on iPhone
Use the stable private **HTTPS** address described in [Deployment](deployment.md).
diff --git a/public/app.js b/public/app.js
index f33df1d..0c7c601 100644
--- a/public/app.js
+++ b/public/app.js
@@ -123,6 +123,14 @@ const statusBadge = (status) =>
label(status),
);
+const choreAssigneeBadge = (ticket) =>
+ ticket.is_chore &&
+ el(
+ 'span',
+ { class: 'badge chore-assignee-badge' },
+ `Chore · ${ticket.assignee_name ? `Assigned to ${ticket.assignee_name}` : 'Unassigned'}`,
+ );
+
const priorityBadge = (priority) =>
el(
'span',
@@ -1055,6 +1063,7 @@ function ticketRow(ticket, onToggle) {
),
statusBadge(ticket.status),
priorityBadge(ticket.priority),
+ choreAssigneeBadge(ticket),
ticket.device_name && el('span', {}, `· ${ticket.device_name}`),
ticket.due_date &&
el(
@@ -1174,6 +1183,7 @@ async function renderTicketDetail(view, id) {
el('span', { class: 'id mono muted' }, `#${ticket.id}`),
statusBadge(ticket.status),
priorityBadge(ticket.priority),
+ choreAssigneeBadge(ticket),
el(
'span',
{ class: 'badge queue-badge' },
diff --git a/public/index.html b/public/index.html
index 1308ecd..2ae85c0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -44,6 +44,7 @@
Dashboard
Devices
Schedules
+ Chores
diff --git a/public/planning.js b/public/planning.js
index 0f537e2..b70d3f5 100644
--- a/public/planning.js
+++ b/public/planning.js
@@ -749,7 +749,7 @@ export function recurrenceLabel(schedule) {
: `Every ${r.days} days`;
}
-export async function recurrenceModal(ticket = null, schedule = null) {
+export async function recurrenceModal(ticket = null, schedule = null, isChore = false) {
const { el, api, field, select, openModal } = ui;
const projects = await api('/projects'),
devices = await api('/devices'),
@@ -849,7 +849,7 @@ export async function recurrenceModal(ticket = null, schedule = null) {
kind.addEventListener('change', draw);
draw();
openModal(
- schedule ? 'Edit routine' : 'New routine',
+ schedule ? (isChore || schedule.is_chore ? 'Edit chore' : 'Edit routine') : (isChore ? 'New chore' : 'New routine'),
() =>
el(
'div',
@@ -954,6 +954,7 @@ export async function recurrenceModal(ticket = null, schedule = null) {
project_id: data.project_id || null,
device_id: data.device_id || null,
recurrence,
+ ...(isChore || schedule?.is_chore ? { is_chore: true } : {}),
checklist: data.checklist
.split('\n')
.map((s) => s.trim())
@@ -964,13 +965,127 @@ export async function recurrenceModal(ticket = null, schedule = null) {
`/schedules${schedule ? '/' + schedule.id : ''}`,
{ method: schedule ? 'PATCH' : 'POST', body },
);
- location.hash = `#/schedules/${result.id}`;
+ location.hash = isChore || schedule?.is_chore ? '#/chores' : `#/schedules/${result.id}`;
await ui.render();
},
);
}
+const choreDate = (date) => new Date(`${date}T12:00:00Z`);
+const isoDate = (date) => date.toISOString().slice(0, 10);
+const shiftDate = (date, days) => { const d = choreDate(date); d.setUTCDate(d.getUTCDate() + days); return isoDate(d); };
+const weekStart = (date) => shiftDate(date, -choreDate(date).getUTCDay());
+const prettyDate = (date, options = { weekday: 'short', month: 'short', day: 'numeric' }) => choreDate(date).toLocaleDateString(undefined, options);
+
+function memberSetup(members) {
+ const { el, field, openModal } = ui;
+ openModal('People on this roster', () => el('div', {},
+ el('p', { class: 'muted' }, 'Use the names you want to see beside each week’s chores.'),
+ ...members.map((member, i) => field(`Person ${i + 1}`, el('input', { name: `member_${member.id}`, value: member.name || '', required: true, maxlength: 80 }))),
+ ), async (data) => {
+ for (const member of members) await ui.api(`/chores/members/${member.id}`, { method: 'PATCH', body: { name: data[`member_${member.id}`] } });
+ await ui.render();
+ });
+}
+
+function choreCard(item, members, today) {
+ const { el, api, field, select } = ui;
+ const overdue = item.status !== 'resolved' && item.status !== 'closed' && item.due_date < today;
+ const completed = item.status === 'resolved' || item.status === 'closed';
+ const memberOptions = members.map((m) => [m.id, m.name]);
+ const assignmentEvents = (item.events || []).filter((event) => event.kind === 'assignee');
+ const eventLine = (event) => {
+ const from = event.from_value || 'Unassigned';
+ const to = event.to_value || 'Unassigned';
+ const change = from === 'Unassigned' ? `Assigned to ${to}` : to === 'Unassigned' ? `Unassigned from ${from}` : `Reassigned from ${from} to ${to}`;
+ const timestamp = event.created_at
+ ? new Date(`${String(event.created_at).replace(' ', 'T')}Z`).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
+ : '';
+ return timestamp ? `${change} · ${timestamp}` : change;
+ };
+ return el('article', { class: `chore-item${completed ? ' is-complete' : ''}${overdue ? ' is-overdue' : ''}` },
+ el('div', { class: 'chore-item-main' },
+ el('span', { class: 'chore-date' }, prettyDate(item.due_date, { weekday: 'short', month: 'short', day: 'numeric' })),
+ el('div', { class: 'chore-copy' },
+ el('a', { class: 'chore-title', href: `#/tickets/${item.id}` }, item.title),
+ el('span', { class: `chore-status${overdue ? ' overdue' : ''}` }, completed ? 'Done' : overdue ? 'Overdue' : (item.status === 'in_progress' ? 'In progress' : item.status === 'blocked' ? 'Blocked' : 'To do')),
+ ),
+ field('Assigned to', select(`assignee_${item.id}`, memberOptions, item.assignee_id, ui.guard(async (e) => {
+ await api(`/tickets/${item.id}`, { method: 'PATCH', body: { assignee_id: Number(e.target.value) } });
+ await ui.render();
+ }))),
+ ),
+ !completed && el('div', { class: 'chore-card-actions' },
+ el('button', { type: 'button', class: 'btn btn-sm chore-done', onclick: ui.guard(async () => {
+ await api(`/tickets/${item.id}`, { method: 'PATCH', body: { status: 'resolved' } });
+ await ui.render();
+ }) }, 'Mark done'),
+ item.original_due_date && item.original_due_date !== item.due_date && el('span', { class: 'muted' }, `Originally due ${prettyDate(item.original_due_date)}`),
+ ),
+ assignmentEvents.length > 0 && el('details', { class: 'chore-history' }, el('summary', {}, 'Assignment history'),
+ ...assignmentEvents.map((event) => el('p', {}, eventLine(event)))),
+ );
+}
+
+async function renderChores(view, query = {}) {
+ const { el, api } = ui;
+ const payload = await api(`/chores${query.week ? `?week=${encodeURIComponent(query.week)}` : ''}`);
+ const start = payload.week_start || weekStart(query.week || payload.today);
+ const end = payload.week_end || shiftDate(start, 6);
+ const currentWeek = start === weekStart(payload.today);
+ const activeTemplates = payload.chores.filter((c) => !c.archived);
+ const archivedTemplates = payload.chores.filter((c) => c.archived);
+ const templateCards = activeTemplates.map((schedule) => el('article', { class: 'template-row' },
+ el('div', {}, el('strong', {}, schedule.title), el('span', {}, recurrenceLabel(schedule))),
+ el('div', { class: 'planning-actions' },
+ button('Edit chore', () => recurrenceModal(null, schedule, true)),
+ button('Archive', async () => {
+ await api(`/schedules/${schedule.id}`, { method: 'PATCH', body: { archived: true } });
+ await ui.render();
+ }),
+ ),
+ ));
+ const dayCount = payload.assignments.filter((a) => a.status !== 'resolved' && a.status !== 'closed').length;
+ const dayLabel = currentWeek ? 'This week' : (start > weekStart(payload.today) ? 'Coming up' : 'Past week');
+ view.append(el('section', { class: 'chores-hero' },
+ el('div', { class: 'chores-kicker' }, 'THE HOME ROUTINE'),
+ el('div', { class: 'chores-hero-line' },
+ el('div', {}, el('h1', {}, 'Chores'), el('p', {}, 'A shared plan for the everyday things.'),
+ el('div', { class: 'week-switch' },
+ el('a', { class: 'btn btn-sm', href: `#/chores?week=${shiftDate(start, -7)}`, 'aria-label': 'Previous week' }, '←'),
+ el('strong', {}, `${prettyDate(start, { month: 'long', day: 'numeric' })} – ${prettyDate(end, { month: 'long', day: 'numeric', year: 'numeric' })}`),
+ el('a', { class: 'btn btn-sm', href: `#/chores?week=${shiftDate(start, 7)}`, 'aria-label': 'Next week' }, '→'),
+ !currentWeek && el('a', { class: 'week-current', href: '#/chores' }, 'Today'),
+ ),
+ ),
+ el('div', { class: 'chores-count' }, el('span', {}, dayLabel), el('strong', {}, String(dayCount)), el('span', {}, 'still to do')),
+ ),
+ el('div', { class: 'chores-actions' },
+ button('People', () => memberSetup(payload.members)),
+ button('Add a chore', () => recurrenceModal(null, null, true), true),
+ ),
+ ));
+ view.append(el('div', { class: 'roster-grid' }, ...payload.members.map((member, index) => {
+ const assigned = payload.assignments.filter((item) => Number(item.assignee_id) === Number(member.id)).sort((a, b) => a.due_date.localeCompare(b.due_date));
+ const complete = assigned.filter((a) => ['resolved', 'closed'].includes(a.status)).length;
+ return el('section', { class: `roster-column person-${index % 2}` },
+ el('header', { class: 'roster-heading' }, el('span', { class: 'person-dot' }), el('h2', {}, member.name), el('span', { class: 'roster-progress' }, `${complete}/${assigned.length}`)),
+ assigned.length ? el('div', { class: 'chore-stack' }, ...assigned.map((a) => choreCard(a, payload.members, payload.today))) : el('div', { class: 'roster-empty' }, 'No chores assigned this week.'),
+ );
+ })));
+ if (payload.previews?.length) view.append(el('section', { class: 'preview-section' },
+ el('div', { class: 'section-intro' }, el('div', {}, el('span', { class: 'chores-kicker' }, 'NOT COMMITTED'), el('h2', {}, 'Coming up')), el('p', {}, 'These dates depend on finishing the current occurrence. The next chores are not assigned yet.')),
+ el('div', { class: 'preview-list' }, ...payload.previews.map((p) => el('div', { class: 'preview-row' }, el('span', {}, prettyDate(p.due_date)), el('strong', {}, p.title), el('span', { class: 'preview-tag' }, 'Preview')))),
+ ));
+ view.append(el('section', { class: 'templates-section' },
+ el('div', { class: 'section-intro' }, el('div', {}, el('span', { class: 'chores-kicker' }, 'THE REPEATERS'), el('h2', {}, 'Chore templates')), el('p', {}, 'Edit a chore or archive it when it’s no longer part of the rotation. Past assignments stay in the roster.')),
+ activeTemplates.length ? el('div', { class: 'template-list' }, ...templateCards) : el('div', { class: 'roster-empty' }, 'No chore templates yet. Add a chore to start the rotation.'),
+ archivedTemplates.length ? el('details', { class: 'archived-chores' }, el('summary', {}, `Archived templates · ${archivedTemplates.length}`), ...archivedTemplates.map((c) => el('p', {}, `${c.title} · ${recurrenceLabel(c)}`))) : null,
+ ));
+}
+
export const planningRoutes = [
+ [/^\/chores$/, renderChores, 'chores'],
[/^\/today$/, renderToday, 'today'],
[/^\/waiting$/, (view) => renderDeferred(view, 'waiting'), 'waiting'],
[/^\/snoozed$/, (view) => renderDeferred(view, 'snoozed'), 'snoozed'],
diff --git a/public/styles.css b/public/styles.css
index 6011905..567289f 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -293,6 +293,7 @@ label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mut
.ticket-row { flex-wrap: wrap; }
.ticket-row > .task-actions { width: 100%; padding-left: 30px; }
.queue-badge { background: var(--accent-soft); color: var(--accent); }
+.chore-assignee-badge { background: color-mix(in srgb, #c99a62 15%, var(--bg-inset)); color: color-mix(in srgb, #d5a66d 85%, var(--text)); border: 1px solid color-mix(in srgb, #c99a62 28%, transparent); }
.secondary-nav { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 20px; padding: 12px 20px 0; color: var(--text-muted); font-size: 13px; }
.secondary-nav a:hover, .secondary-nav a.active { color: var(--accent); }
.capture-details summary { cursor: pointer; padding: 12px 0; color: var(--text-muted); }
@@ -764,6 +765,89 @@ progress { width: 100%; height: 8px; accent-color: var(--accent); }
.draft-meta { font-size: 12px; color: var(--text-muted); }
.planning-actions input { flex: 1; min-width: 150px; }
.ticket-row .sub { overflow-wrap: anywhere; }
+
+/* ---- Chores -------------------------------------------------------------- */
+.chores-hero {
+ position: relative;
+ overflow: hidden;
+ margin: -4px 0 24px;
+ padding: clamp(22px, 4vw, 40px);
+ border: 1px solid color-mix(in srgb, #d2a46b 25%, var(--border));
+ border-radius: 18px;
+ background: radial-gradient(ellipse at 94% 4%, rgba(202, 148, 87, .17), transparent 38%), linear-gradient(135deg, color-mix(in srgb, #31271f 35%, var(--bg-raised)), var(--bg-raised) 62%);
+}
+.chores-kicker { color: #c99a62; font-size: 10px; font-weight: 750; letter-spacing: .16em; }
+.chores-hero-line { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; }
+.chores-hero h1 { margin: 8px 0 2px; font-family: Georgia, "Times New Roman", serif; font-weight: 500; letter-spacing: -.045em; font-size: clamp(34px, 5vw, 48px); line-height: 1.1; }
+.chores-hero p { margin: 0; color: var(--text-muted); }
+.chores-count { display: grid; text-align: right; color: var(--text-muted); font-size: 12px; }
+.chores-count strong { font-family: Georgia, serif; font-size: 34px; font-weight: 500; line-height: 1; color: var(--text); }
+.week-switch { display: flex; align-items: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
+.week-switch strong { font-size: 13px; font-variant-numeric: tabular-nums; }
+.week-current { color: #c99a62; font-size: 12px; }
+.chores-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
+.chores-hero .btn-primary { background: #b67b41; border-color: #b67b41; }
+.roster-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
+.roster-column { min-width: 0; }
+.roster-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 0 4px; }
+.roster-heading h2 { margin: 0; font-family: Georgia, serif; font-size: 21px; font-weight: 500; letter-spacing: -.02em; }
+.person-dot { width: 9px; height: 9px; border-radius: 50%; background: #cf9560; box-shadow: 0 0 0 4px rgba(207,149,96,.12); }
+.person-1 .person-dot { background: #7fa5aa; box-shadow: 0 0 0 4px rgba(127,165,170,.12); }
+.roster-progress { margin-left: auto; color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
+.chore-stack { display: grid; gap: 8px; }
+.chore-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-raised); transition: border-color .16s, transform .16s; }
+.chore-item:hover { border-color: color-mix(in srgb, #c99a62 48%, var(--border)); transform: translateY(-1px); }
+.chore-item-main { display: grid; grid-template-columns: 62px minmax(0, 1fr) minmax(110px, 150px); align-items: center; gap: 10px; }
+.chore-date { color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
+.chore-copy { min-width: 0; display: grid; gap: 2px; }
+.chore-title { font-weight: 620; overflow-wrap: anywhere; }
+.chore-title:hover { color: #c99a62; }
+.chore-status { color: var(--text-muted); font-size: 11px; }
+.chore-status.overdue { color: var(--critical); }
+.chore-item.is-complete .chore-title { color: var(--text-muted); }
+.chore-item.is-overdue { border-left: 2px solid var(--critical); }
+.chore-item .field { margin: 0; }
+.chore-item .field label { position: static; display: block; width: auto; height: auto; overflow: visible; clip: auto; margin: 0 0 4px; font-size: 10px; line-height: 1.2; }
+.chore-item select { width: 100%; min-height: 36px; padding: 6px 7px; font-size: 12px; cursor: pointer; }
+.chore-card-actions { display: flex; align-items: center; gap: 9px; margin: 8px 0 0 72px; }
+.chore-done { color: #c99a62; }
+.chore-history { margin: 10px 0 0 72px; border-top: 1px solid var(--border); padding-top: 7px; color: var(--text-muted); font-size: 11px; }
+.chore-history summary, .archived-chores summary { cursor: pointer; }
+.chore-history p { margin: 5px 0 0; }
+.roster-empty { padding: 20px 14px; border: 1px dashed var(--border-strong); border-radius: 11px; color: var(--text-muted); font-size: 13px; }
+.preview-section, .templates-section { margin-top: 42px; }
+.section-intro { display: flex; justify-content: space-between; align-items: end; gap: 18px; margin-bottom: 12px; }
+.section-intro h2 { margin: 3px 0 0; font-family: Georgia, serif; font-size: 25px; font-weight: 500; letter-spacing: -.025em; }
+.section-intro p { max-width: 390px; margin: 0; color: var(--text-muted); font-size: 12px; }
+.preview-list, .template-list { border-top: 1px solid var(--border); }
+.preview-row, .template-row { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 12px 4px; border-bottom: 1px solid var(--border); }
+.preview-row > span:first-child { min-width: 95px; color: var(--text-muted); font-size: 12px; }
+.preview-row strong { flex: 1; font-weight: 550; }
+.preview-tag { border: 1px dashed var(--border-strong); border-radius: 20px; padding: 2px 8px; color: var(--text-muted); font-size: 10px; }
+.template-row > div:first-child { display: grid; gap: 2px; }
+.template-row > div:first-child span { color: var(--text-muted); font-size: 12px; }
+.archived-chores { margin-top: 14px; color: var(--text-muted); font-size: 12px; }
+.archived-chores p { margin: 8px 0; }
+@media (max-width: 760px) {
+ .chores-hero-line { align-items: flex-start; }
+ .chores-count { display: none; }
+ .roster-grid { grid-template-columns: 1fr; gap: 24px; }
+ .section-intro { display: block; }
+ .section-intro p { margin-top: 6px; }
+}
+@media (max-width: 440px) {
+ .chores-hero { padding: 20px 16px; }
+ .chores-actions { justify-content: stretch; }
+ .chores-actions .btn { flex: 1; justify-content: center; }
+ .chore-item-main { grid-template-columns: 1fr auto; gap: 5px 10px; }
+ .chore-date { grid-column: 1; }
+ .chore-copy { grid-column: 1; }
+ .chore-item .field { grid-column: 2; grid-row: 1 / span 2; width: 136px; }
+ .chore-history { margin-left: 0; }
+ .chore-card-actions { margin-left: 0; }
+ .template-row { align-items: flex-start; }
+ .template-row .planning-actions { flex-wrap: wrap; justify-content: flex-end; }
+}
@media (max-width: 760px) {
.topbar { height: auto; min-height: 64px; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.topbar .brand { flex: 1; }
diff --git a/public/sw.js b/public/sw.js
index ec5a225..d0c3d5a 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'taskhub-capture-v1';
+const CACHE = 'taskhub-capture-v2';
const ASSETS = [
'/capture.html',
'/app.js',
diff --git a/src/api/schedules.js b/src/api/schedules.js
index 6d9c420..b04f90c 100644
--- a/src/api/schedules.js
+++ b/src/api/schedules.js
@@ -1,6 +1,7 @@
import { transaction } from '../db.js';
import { createTicket, getTicket } from './tickets.js';
import { civilDate, addDays, timeZoneFor } from '../dates.js';
+import { listEvents } from './events.js';
import {
parseRecurrence,
nextOccurrence,
@@ -44,6 +45,14 @@ export function listSchedules(db, query = {}) {
where.push('s.paused = :paused');
params.paused = boolean(query.paused, 'paused') ? 1 : 0;
}
+ if (query.is_chore !== undefined && query.is_chore !== '') {
+ where.push('s.is_chore = :is_chore');
+ params.is_chore = boolean(query.is_chore, 'is_chore') ? 1 : 0;
+ }
+ if (query.archived !== undefined && query.archived !== '') {
+ where.push('s.archived = :archived');
+ params.archived = boolean(query.archived, 'archived') ? 1 : 0;
+ }
const sql = `${SELECT_SCHEDULE}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
@@ -52,6 +61,126 @@ export function listSchedules(db, query = {}) {
return db.prepare(sql).all(params).map(shapeSchedule);
}
+/**
+ * Returns the durable two-person chore roster and assignments for one
+ * Sunday–Saturday week. Reading a past week is deliberately read-only.
+ */
+export function getChoreRoster(db, { week, today: suppliedToday } = {}) {
+ const today = rosterToday(db, suppliedToday);
+ const weekStart = week === undefined || week === null
+ ? sundayFor(today)
+ : optionalDate(week, 'week');
+ if (
+ !weekStart ||
+ new Date(`${weekStart}T12:00:00Z`).getUTCDay() !== 0
+ )
+ throw new ValidationError('week must be a Sunday in YYYY-MM-DD format');
+ const weekEnd = addDays(weekStart, 6);
+
+ const members = db
+ .prepare('SELECT id, name FROM household_members ORDER BY id')
+ .all()
+ .map(({ id, name }) => ({ id, name }));
+ const chores = db
+ .prepare(`${SELECT_SCHEDULE} WHERE s.is_chore = 1 ORDER BY s.id`)
+ .all()
+ .map(shapeSchedule);
+ const assignments = db
+ .prepare(
+ `SELECT t.id, t.schedule_id, t.assignee_id,
+ m.name AS assignee_name, t.due_date, t.original_due_date,
+ t.status, t.title,
+ COALESCE(t.original_due_date, t.due_date) AS assignment_date
+ FROM tickets t
+ JOIN schedules s ON s.id = t.schedule_id AND s.is_chore = 1
+ LEFT JOIN household_members m ON m.id = t.assignee_id
+ WHERE (
+ COALESCE(t.original_due_date, t.due_date) BETWEEN ? AND ?
+ OR (COALESCE(t.original_due_date, t.due_date) < ?
+ AND t.status NOT IN ('resolved', 'closed'))
+ )
+ ORDER BY assignment_date, t.id`,
+ )
+ .all(weekStart, weekEnd, weekStart)
+ .map(({ assignment_date: _assignmentDate, ...row }) => ({
+ ...row,
+ events: listEvents(db, row.id),
+ }));
+
+ const previews = [];
+ const minPreviewDate = weekStart === sundayFor(today) ? today : weekStart;
+ for (const chore of chores) {
+ if (chore.archived || chore.paused || !chore.recurrence) continue;
+ const open = db
+ .prepare(
+ `SELECT id, due_date, original_due_date
+ FROM tickets
+ WHERE schedule_id = ? AND status NOT IN ('resolved', 'closed')
+ ORDER BY id DESC LIMIT 1`,
+ )
+ .get(chore.id);
+ let dueDate;
+ if (open) {
+ if (chore.recurrence.kind === 'after_completion') continue;
+ const anchor =
+ open.original_due_date ?? open.due_date ?? chore.last_due ?? chore.next_due;
+ dueDate = nextOccurrence(chore.recurrence, anchor, anchor);
+ } else {
+ dueDate = chore.next_due;
+ }
+ if (
+ dueDate >= minPreviewDate &&
+ dueDate >= weekStart &&
+ dueDate <= weekEnd &&
+ !db
+ .prepare(
+ 'SELECT 1 FROM tickets WHERE schedule_id = ? AND original_due_date = ? LIMIT 1',
+ )
+ .get(chore.id, dueDate)
+ ) {
+ previews.push({
+ schedule_id: chore.id,
+ title: chore.title,
+ due_date: dueDate,
+ conditional: true,
+ });
+ }
+ }
+
+ return {
+ members,
+ chores,
+ week_start: weekStart,
+ week_end: weekEnd,
+ assignments,
+ previews,
+ today,
+ };
+}
+
+/** Updates the display name for one of the two stable household identities. */
+export function updateHouseholdMember(db, id, { name } = {}) {
+ const memberId = optionalId(id, 'id');
+ if (![1, 2].includes(memberId))
+ throw new NotFoundError(`No household member with id ${memberId}`);
+ const memberName = requiredText(name, 'name', 100);
+ if (!db.prepare('SELECT id FROM household_members WHERE id = ?').get(memberId))
+ throw new NotFoundError(`No household member with id ${memberId}`);
+ if (
+ db
+ .prepare('SELECT id FROM household_members WHERE name = ? AND id <> ?')
+ .get(memberName, memberId)
+ )
+ throw new ValidationError('Household member names must be unique');
+ db.prepare(
+ "UPDATE household_members SET name = ?, updated_at = datetime('now') WHERE id = ?",
+ ).run(memberName, memberId);
+ const member = db
+ .prepare('SELECT id, name FROM household_members WHERE id = ?')
+ .get(memberId);
+ return { id: member.id, name: member.name };
+}
+
/** The schedule plus the tickets it has generated, most recent first. */
export function getSchedule(db, id) {
const row = db.prepare(`${SELECT_SCHEDULE} WHERE s.id = ?`).get(id);
@@ -59,7 +188,8 @@ export function getSchedule(db, id) {
const tickets = db
.prepare(
- `SELECT id, title, status, priority, due_date, resolved_at, created_at
+ `SELECT id, title, status, priority, due_date, resolved_at, created_at,
+ assignee_id, original_due_date
FROM tickets WHERE schedule_id = ? ORDER BY created_at DESC LIMIT 20`,
)
.all(id);
@@ -69,6 +199,8 @@ export function getSchedule(db, id) {
export function createSchedule(db, input = {}) {
const fields = parseSchedule(db, input, { partial: false });
+ if (fields.is_chore && input.source_ticket_id)
+ throw new ValidationError('Chores cannot be created from an existing ticket');
return transaction(db, () => {
const { lastInsertRowid } = db
.prepare(
@@ -102,6 +234,18 @@ export function createSchedule(db, input = {}) {
export function updateSchedule(db, id, input = {}) {
const existing = getSchedule(db, id);
+ if (
+ Object.hasOwn(input, 'is_chore') &&
+ boolean(input.is_chore, 'is_chore') !== existing.is_chore
+ )
+ throw new ValidationError('is_chore cannot be changed after schedule creation');
+ if (
+ existing.is_chore &&
+ existing.archived &&
+ Object.hasOwn(input, 'archived') &&
+ !boolean(input.archived, 'archived')
+ )
+ throw new ValidationError('Archived chore schedules cannot be unarchived');
const fields = parseSchedule(db, input, { partial: true, existing });
const rule = Object.hasOwn(fields, 'recurrence')
@@ -138,7 +282,13 @@ export function updateSchedule(db, id, input = {}) {
* schema's ON DELETE SET NULL — the same reasoning as deleting a device.
*/
export function deleteSchedule(db, id) {
- getSchedule(db, id);
+ const schedule = getSchedule(db, id);
+ if (schedule.is_chore) {
+ db.prepare(
+ "UPDATE schedules SET archived = 1, updated_at = datetime('now') WHERE id = ?",
+ ).run(id);
+ return;
+ }
db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
}
@@ -153,11 +303,13 @@ export function deleteSchedule(db, id) {
*
* Returns what it created, which is what the notifier reports on.
*/
-export function runSchedules(db, { today } = {}) {
+export function runSchedules(db, { today, onlyChores = false } = {}) {
+ const injectedToday = today == null ? null : optionalDate(today, 'today');
+ const choresOnly = boolean(onlyChores, 'onlyChores');
const due = db
.prepare(
`SELECT * FROM schedules
- WHERE paused = 0
+ WHERE paused = 0 AND archived = 0 ${choresOnly ? 'AND is_chore = 1' : ''}
ORDER BY next_due ASC, id ASC`,
)
.all();
@@ -167,10 +319,17 @@ export function runSchedules(db, { today } = {}) {
for (const schedule of due) {
try {
const date =
- today ??
- (schedule.recurrence
- ? civilDate(new Date(), schedule.time_zone)
- : currentDate(db));
+ injectedToday ??
+ (schedule.is_chore
+ ? civilDate(new Date(), timeZoneFor(db))
+ : schedule.recurrence
+ ? civilDate(new Date(), schedule.time_zone)
+ : currentDate(db));
+ if (schedule.is_chore) {
+ const created = runChoreSchedule(db, schedule, date);
+ if (created) fired.push(created);
+ continue;
+ }
if (addDays(schedule.next_due, -schedule.lead_days) > date) continue;
if (
schedule.recurrence &&
@@ -213,9 +372,9 @@ export function runSchedules(db, { today } = {}) {
return fired;
}
-function fire(db, schedule, today) {
+function fire(db, schedule, today, { assigneeId = null } = {}) {
return transaction(db, () => {
- const ticket = createTicket(
+ let ticket = createTicket(
db,
{
title: schedule.title,
@@ -230,6 +389,19 @@ function fire(db, schedule, today) {
},
{ scheduleId: schedule.id },
);
+ if (schedule.is_chore) {
+ const member = db
+ .prepare('SELECT id, name FROM household_members WHERE id = ?')
+ .get(assigneeId);
+ if (!member) throw new Error(`No household member with id ${assigneeId}`);
+ db.prepare(
+ 'UPDATE tickets SET assignee_id = ?, original_due_date = ? WHERE id = ?',
+ ).run(member.id, schedule.next_due, ticket.id);
+ db.prepare(
+ `INSERT INTO ticket_events (ticket_id, kind, from_value, to_value)
+ VALUES (?, 'assignee', NULL, ?)`,
+ ).run(ticket.id, member.name);
+ }
JSON.parse(schedule.checklist).forEach((title, position) =>
db
.prepare(
@@ -237,6 +409,7 @@ function fire(db, schedule, today) {
)
.run(ticket.id, title, position),
);
+ if (schedule.is_chore) ticket = getTicket(db, ticket.id);
db.prepare(
`UPDATE schedules
@@ -259,6 +432,106 @@ function fire(db, schedule, today) {
});
}
+/** Materializes only a current-week chore occurrence; missed dates are skipped. */
+function runChoreSchedule(db, staleSchedule, today) {
+ const weekStart = sundayFor(today);
+ const weekEnd = addDays(weekStart, 6);
+ return transaction(db, () => {
+ const schedule = db
+ .prepare('SELECT * FROM schedules WHERE id = ?')
+ .get(staleSchedule.id);
+ if (!schedule || schedule.archived || schedule.paused) return null;
+ if (!schedule.recurrence)
+ throw new ValidationError(`Chore schedule ${schedule.id} needs recurrence`);
+ if (
+ db
+ .prepare(
+ "SELECT 1 FROM tickets WHERE schedule_id = ? AND status NOT IN ('resolved','closed') LIMIT 1",
+ )
+ .get(schedule.id)
+ )
+ return null;
+
+ let dueDate = schedule.next_due;
+ if (dueDate < today) {
+ dueDate = nextOccurrence(
+ JSON.parse(schedule.recurrence),
+ dueDate,
+ addDays(today, -1),
+ );
+ db.prepare(
+ "UPDATE schedules SET next_due = ?, updated_at = datetime('now') WHERE id = ?",
+ ).run(dueDate, schedule.id);
+ schedule.next_due = dueDate;
+ }
+ if (dueDate < today || dueDate < weekStart || dueDate > weekEnd) return null;
+ if (
+ db
+ .prepare(
+ 'SELECT 1 FROM tickets WHERE schedule_id = ? AND original_due_date = ? LIMIT 1',
+ )
+ .get(schedule.id, dueDate)
+ )
+ return null;
+
+ const assigneeId = chooseAssignee(db, schedule.id, weekStart, weekEnd);
+ return fire(db, schedule, today, { assigneeId });
+ });
+}
+
+/** Balances weekly work first, then avoids the schedule's last actual assignee. */
+function chooseAssignee(db, scheduleId, weekStart, weekEnd) {
+ const lookback = addDays(weekStart, -28);
+ const lookbackEnd = addDays(weekStart, -1);
+ const weekNumber = Math.floor(
+ Date.parse(`${weekStart}T12:00:00Z`) / (7 * 24 * 60 * 60 * 1000),
+ );
+ const fairTieMemberId = (((weekNumber + scheduleId) % 2 + 2) % 2) + 1;
+ const load = new Map(
+ db
+ .prepare(
+ `SELECT assignee_id,
+ SUM(CASE
+ WHEN COALESCE(original_due_date, due_date) BETWEEN ? AND ?
+ OR (COALESCE(original_due_date, due_date) < ?
+ AND status NOT IN ('resolved', 'closed'))
+ THEN 1 ELSE 0 END) AS week_count,
+ SUM(CASE
+ WHEN COALESCE(original_due_date, due_date) BETWEEN ? AND ?
+ THEN 1 ELSE 0 END) AS recent_count
+ FROM tickets
+ WHERE assignee_id IS NOT NULL
+ GROUP BY assignee_id`,
+ )
+ .all(weekStart, weekEnd, weekStart, lookback, lookbackEnd)
+ .map((row) => [row.assignee_id, row]),
+ );
+ const previous = db
+ .prepare(
+ `SELECT assignee_id FROM tickets
+ WHERE schedule_id = ? AND assignee_id IS NOT NULL
+ ORDER BY COALESCE(original_due_date, due_date) DESC, id DESC LIMIT 1`,
+ )
+ .get(scheduleId)?.assignee_id;
+ const members = db
+ .prepare('SELECT id FROM household_members ORDER BY id')
+ .all();
+ if (members.length !== 2)
+ throw new Error('The chore roster must contain exactly two members');
+ members.sort((a, b) => {
+ const aLoad = load.get(a.id) ?? { week_count: 0, recent_count: 0 };
+ const bLoad = load.get(b.id) ?? { week_count: 0, recent_count: 0 };
+ return (
+ aLoad.week_count - bLoad.week_count ||
+ Number(a.id === previous) - Number(b.id === previous) ||
+ aLoad.recent_count - bLoad.recent_count ||
+ Number(b.id === fairTieMemberId) - Number(a.id === fairTieMemberId) ||
+ a.id - b.id
+ );
+ });
+ return members[0].id;
+}
+
/** Walks forward by whole intervals until the date is in the future. */
function advance(db, from, intervalDays, today) {
// Validation keeps this above zero, so reaching here means the row was
@@ -297,6 +570,22 @@ function parseSchedule(db, input, { partial, existing = null }) {
fields.tags = (tagList(input.tags) ?? []).join(',');
if (!partial || has('paused'))
fields.paused = boolean(input.paused, 'paused') ? 1 : 0;
+ if (!partial || has('is_chore'))
+ fields.is_chore = boolean(
+ input.is_chore,
+ 'is_chore',
+ Boolean(existing?.is_chore),
+ )
+ ? 1
+ : 0;
+ if (!partial || has('archived'))
+ fields.archived = boolean(
+ input.archived,
+ 'archived',
+ Boolean(existing?.archived),
+ )
+ ? 1
+ : 0;
if (!partial || has('interval_days')) {
fields.interval_days = boundedInt(input.interval_days, 'interval_days', {
@@ -340,9 +629,20 @@ function parseSchedule(db, input, { partial, existing = null }) {
items.map((t) => requiredText(t, 'checklist item', 500)),
);
}
+ const isChore = fields.is_chore ?? Number(existing?.is_chore ?? 0);
if (!partial || has('time_zone')) {
+ const applicationTimeZone = timeZoneFor(db);
+ if (
+ isChore &&
+ input.time_zone != null &&
+ input.time_zone !== applicationTimeZone
+ )
+ throw new ValidationError(
+ `Chore time_zone must match the application time zone (${applicationTimeZone})`,
+ );
fields.time_zone =
- input.time_zone ?? (input.recurrence ? timeZoneFor(db) : 'UTC');
+ input.time_zone ??
+ (isChore || input.recurrence ? applicationTimeZone : 'UTC');
try {
civilDate(new Date(), fields.time_zone);
} catch {
@@ -354,6 +654,9 @@ function parseSchedule(db, input, { partial, existing = null }) {
: existing?.recurrence
? JSON.stringify(existing.recurrence)
: null;
+ if (isChore && !rule)
+ throw new ValidationError('Chore schedules require a modern recurrence rule');
+ if (isChore) fields.lead_days = 0;
if (rule) {
fields.lead_days = 0;
if (!partial || has('next_due') || has('recurrence')) {
@@ -410,12 +713,41 @@ const splitTags = (csv) => (csv ? csv.split(',').filter(Boolean) : []);
const currentDate = (db) =>
db.prepare(`SELECT date('now') AS today`).get().today;
-function shapeSchedule({ tags, paused, recurrence, checklist, ...schedule }) {
+function shapeSchedule({
+ tags,
+ paused,
+ recurrence,
+ checklist,
+ is_chore,
+ archived,
+ ...schedule
+}) {
return {
...schedule,
tags: splitTags(tags),
paused: Boolean(paused),
+ is_chore: Boolean(is_chore),
+ archived: Boolean(archived),
recurrence: recurrence ? JSON.parse(recurrence) : null,
checklist: JSON.parse(checklist),
};
}
+
+function rosterToday(db, suppliedToday) {
+ if (suppliedToday instanceof Date) {
+ if (Number.isNaN(suppliedToday.getTime()))
+ throw new ValidationError('today must be a valid date');
+ return civilDate(suppliedToday, timeZoneFor(db));
+ }
+ if (suppliedToday !== undefined && suppliedToday !== null) {
+ const value = optionalDate(suppliedToday, 'today');
+ if (value === null) throw new ValidationError('today must be a valid date');
+ return value;
+ }
+ return civilDate(new Date(), timeZoneFor(db));
+}
+
+function sundayFor(date) {
+ const day = new Date(`${date}T12:00:00Z`).getUTCDay();
+ return addDays(date, -day);
+}
diff --git a/src/api/tickets.js b/src/api/tickets.js
index c97c3f8..9831fcb 100644
--- a/src/api/tickets.js
+++ b/src/api/tickets.js
@@ -36,6 +36,11 @@ const SELECT_TICKET = `
d.name AS device_name,
d.type AS device_type,
(SELECT name FROM projects WHERE id = t.project_id) AS project_name,
+ (SELECT name FROM household_members WHERE id = t.assignee_id)
+ AS assignee_name,
+ COALESCE(
+ (SELECT is_chore FROM schedules WHERE id = t.schedule_id), 0
+ ) AS is_chore,
(SELECT count(*) FROM checklist_items WHERE ticket_id = t.id) AS checklist_total,
(SELECT count(*) FROM checklist_items WHERE ticket_id = t.id AND completed = 1) AS checklist_completed,
(SELECT COUNT(*) FROM comments c WHERE c.ticket_id = t.id) AS comment_count,
@@ -191,6 +196,18 @@ export function updateTicket(db, id, input = {}) {
const fields = parseTicket(input, { partial: true });
const tags = tagList(input.tags);
+ if (Object.hasOwn(input, 'assignee_id')) {
+ if (!existing.is_chore)
+ throw new ValidationError('Only chore tickets can be assigned');
+ fields.assignee_id = requiredId(input.assignee_id, 'assignee_id');
+ if (
+ !db
+ .prepare('SELECT 1 FROM household_members WHERE id = ?')
+ .get(fields.assignee_id)
+ )
+ throw new ValidationError(`No household member with id ${fields.assignee_id}`);
+ }
+
if (Object.keys(fields).length === 0 && tags === null) {
throw new ValidationError('No updatable fields provided');
}
@@ -212,6 +229,20 @@ export function updateTicket(db, id, input = {}) {
if (fields.status && fields.status !== existing.status) {
const wasClosed = CLOSED_STATUSES.includes(existing.status);
const isClosed = CLOSED_STATUSES.includes(fields.status);
+ if (existing.is_chore && wasClosed && !isClosed) {
+ const newerUnfinished = db
+ .prepare(
+ `SELECT 1 FROM tickets
+ WHERE schedule_id = ? AND id > ?
+ AND status NOT IN (${CLOSED_LIST})
+ LIMIT 1`,
+ )
+ .get(existing.schedule_id, existing.id);
+ if (newerUnfinished)
+ throw new ValidationError(
+ 'Cannot reopen this chore while a newer occurrence is unfinished',
+ );
+ }
if (isClosed && !wasClosed) fields.resolved_at = isoNow();
if (!isClosed && wasClosed) fields.resolved_at = null;
}
@@ -221,6 +252,9 @@ export function updateTicket(db, id, input = {}) {
const afterDeviceName = Object.hasOwn(fields, 'device_id')
? deviceName(db, fields.device_id)
: existing.device_name;
+ const afterAssigneeName = Object.hasOwn(fields, 'assignee_id')
+ ? memberName(db, fields.assignee_id)
+ : existing.assignee_name;
return transaction(db, () => {
const keys = Object.keys(fields);
@@ -234,6 +268,18 @@ export function updateTicket(db, id, input = {}) {
recordChanges(db, id, existing, fields, {
deviceNames: { before: existing.device_name, after: afterDeviceName },
});
+ if (
+ Object.hasOwn(fields, 'assignee_id') &&
+ fields.assignee_id !== existing.assignee_id
+ ) {
+ recordEvent(
+ db,
+ id,
+ 'assignee',
+ existing.assignee_name ?? null,
+ afterAssigneeName,
+ );
+ }
if (tags !== null) {
const before = existing.tags.join(', ');
@@ -252,7 +298,9 @@ export function updateTicket(db, id, input = {}) {
}
export function deleteTicket(db, id) {
- getTicket(db, id);
+ const ticket = getTicket(db, id);
+ if (ticket.is_chore)
+ throw new ValidationError('Generated chore tickets cannot be deleted');
db.prepare('DELETE FROM tickets WHERE id = ?').run(id);
}
@@ -433,11 +481,23 @@ function deviceName(db, deviceId) {
);
}
+/** The current household member name by id, used for readable event snapshots. */
+function memberName(db, memberId) {
+ if (!memberId) return null;
+ return (
+ db
+ .prepare('SELECT name FROM household_members WHERE id = ?')
+ .get(memberId)?.name ??
+ null
+ );
+}
+
/** Turns the group_concat CSV into a real array for the client. */
function shapeTicket({ tag_csv, ...ticket }) {
return {
...ticket,
tags: tag_csv ? tag_csv.split(',').sort() : [],
+ is_chore: Boolean(ticket.is_chore),
is_open: !CLOSED_STATUSES.includes(ticket.status),
};
}
diff --git a/src/db.js b/src/db.js
index 35d6de2..3bab526 100644
--- a/src/db.js
+++ b/src/db.js
@@ -285,6 +285,32 @@ const MIGRATIONS = [
ALTER TABLE schedules ADD COLUMN last_due TEXT;
UPDATE schedules SET last_due = next_due WHERE recurrence IS NOT NULL AND last_ticket_id IS NOT NULL;
`,
+ `
+ -- Chores are an explicit opt-in schedule type. Existing schedules stay
+ -- routines; adding these flags must never enroll historical rows.
+ ALTER TABLE schedules ADD COLUMN is_chore INTEGER NOT NULL DEFAULT 0
+ CHECK (is_chore IN (0, 1));
+ ALTER TABLE schedules ADD COLUMN archived INTEGER NOT NULL DEFAULT 0
+ CHECK (archived IN (0, 1));
+
+ -- A fixed two-person roster shared by the household account. IDs are
+ -- intentionally stable because tickets keep their original assignee.
+ CREATE TABLE household_members (
+ id INTEGER PRIMARY KEY CHECK (id IN (1, 2)),
+ name TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ INSERT INTO household_members (id, name) VALUES
+ (1, 'Member 1'),
+ (2, 'Member 2');
+
+ ALTER TABLE tickets ADD COLUMN assignee_id INTEGER
+ REFERENCES household_members(id) ON DELETE RESTRICT;
+ ALTER TABLE tickets ADD COLUMN original_due_date TEXT;
+ CREATE INDEX idx_tickets_assignee ON tickets(assignee_id);
+ CREATE INDEX idx_tickets_original_due_date ON tickets(original_due_date);
+ `,
];
/**
diff --git a/src/recurrence.js b/src/recurrence.js
index 0162e07..394d314 100644
--- a/src/recurrence.js
+++ b/src/recurrence.js
@@ -84,10 +84,20 @@ export function syncRecurrence(db, ticketId) {
row.time_zone,
);
const anchor = row.last_due || row.next_due;
- const next =
+ let next =
rule.kind === 'after_completion'
? addDays(completed, rule.days)
: nextOccurrence(rule, anchor, completed > anchor ? completed : anchor);
+ if (
+ row.is_chore &&
+ rule.kind !== 'after_completion' &&
+ next < civilDate(new Date(), row.time_zone)
+ ) {
+ // A chore completed after its next calendar date has passed should move to
+ // the next live occurrence, not create a historical catch-up assignment.
+ const today = civilDate(new Date(), row.time_zone);
+ next = nextOccurrence(rule, next, addDays(today, -1));
+ }
db.prepare('UPDATE schedules SET next_due = ? WHERE id = ?').run(
next,
row.id,
diff --git a/src/server.js b/src/server.js
index f072ef0..50b0ddd 100644
--- a/src/server.js
+++ b/src/server.js
@@ -39,6 +39,8 @@ import {
updateSchedule,
deleteSchedule,
runSchedules,
+ getChoreRoster,
+ updateHouseholdMember,
} from './api/schedules.js';
import { exportEntity } from './api/export.js';
import { renderMetrics } from './api/metrics.js';
@@ -186,6 +188,12 @@ const ROUTES = [
['GET', '/api/calendar.ics', sendCalendar],
['GET', '/api/export', sendExport],
['POST', '/api/maintenance/run', runMaintenance, 200],
+ ['GET', '/api/chores', readChoreRoster],
+ [
+ 'PATCH',
+ '/api/chores/members/:id',
+ ({ db, params, body }) => updateHouseholdMember(db, params.id, body),
+ ],
['GET', '/api/devices', ({ db, query }) => listDevices(db, query)],
['POST', '/api/devices', ({ db, body }) => createDevice(db, body)],
@@ -281,6 +289,10 @@ const ROUTES = [
/* ---- Handlers that need more than the data layer ------------------------ */
function createTicketAndNotify({ db, body, notifier, req }) {
+ if (Object.hasOwn(body, 'assignee_id'))
+ throw new ValidationError(
+ 'Assignments can only be changed on chore tickets',
+ );
const { value, fresh } = submitOnce(
db,
'ticket',
@@ -293,6 +305,15 @@ function createTicketAndNotify({ db, body, notifier, req }) {
return value;
}
+function readChoreRoster({ db, query, notifier }) {
+ const roster = getChoreRoster(db, { week: query.week });
+ if (roster.week_start !== getChoreRoster(db).week_start) return roster;
+ const fired = runSchedules(db, { onlyChores: true });
+ for (const { ticket } of fired)
+ notifier.sendDetached('schedule.fired', ticket);
+ return getChoreRoster(db, { week: query.week });
+}
+
function updateTicketAndNotify({ db, params, body, notifier }) {
const before = getTicket(db, params.id);
const ticket = updateTicket(db, params.id, body);
diff --git a/test/chores.test.js b/test/chores.test.js
new file mode 100644
index 0000000..70b2898
--- /dev/null
+++ b/test/chores.test.js
@@ -0,0 +1,391 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { openDatabase } from '../src/db.js';
+import {
+ createSchedule,
+ getChoreRoster,
+ getSchedule,
+ runSchedules,
+ updateSchedule,
+} from '../src/api/schedules.js';
+import { createTicket, updateTicket } from '../src/api/tickets.js';
+import { configureDates } from '../src/dates.js';
+import { syncRecurrence } from '../src/recurrence.js';
+
+function database(t, timeZone = 'UTC') {
+ const db = openDatabase(':memory:');
+ configureDates(db, timeZone);
+ t.after(() => db.close());
+ return db;
+}
+
+function createChore(db, { title, next_due, recurrence, ...fields }) {
+ return createSchedule(db, {
+ title,
+ next_due,
+ recurrence,
+ is_chore: true,
+ time_zone: 'UTC',
+ ...fields,
+ });
+}
+
+function completeAt(db, ticket, date) {
+ db.prepare(
+ "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?",
+ ).run(`${date} 12:00:00`, ticket.id);
+ syncRecurrence(db, ticket.id);
+}
+
+test('chore recurrence supports selected weekdays, monthly rules, intervals, and completion-based dates', async (t) => {
+ const cases = [
+ {
+ name: 'weekly selected days',
+ next_due: '2030-01-01',
+ recurrence: { kind: 'weekly', weekdays: [2, 5] },
+ due: '2030-01-01',
+ completed: '2030-01-01',
+ next: '2030-01-04',
+ },
+ {
+ name: 'monthly date clamps to leap-day',
+ next_due: '2032-01-31',
+ recurrence: { kind: 'monthly_date', day: 31 },
+ due: '2032-01-31',
+ completed: '2032-01-31',
+ next: '2032-02-29',
+ },
+ {
+ name: 'monthly fifth weekday means the last weekday',
+ next_due: '2030-01-01',
+ recurrence: { kind: 'monthly_weekday', ordinal: 5, weekday: 0 },
+ due: '2030-01-27',
+ completed: '2030-01-27',
+ next: '2030-02-24',
+ },
+ {
+ name: 'interval cadence remains anchored to its due date',
+ next_due: '2030-03-01',
+ recurrence: { kind: 'interval', days: 4 },
+ due: '2030-03-01',
+ completed: '2030-03-05',
+ next: '2030-03-09',
+ },
+ {
+ name: 'after-completion cadence starts from completion',
+ next_due: '2030-04-01',
+ recurrence: { kind: 'after_completion', days: 7 },
+ due: '2030-04-01',
+ completed: '2030-04-05',
+ next: '2030-04-12',
+ },
+ ];
+
+ for (const scenario of cases) {
+ await t.test(scenario.name, (t) => {
+ const db = database(t);
+ const schedule = createChore(db, {
+ title: scenario.name,
+ next_due: scenario.next_due,
+ recurrence: scenario.recurrence,
+ });
+
+ const [first] = runSchedules(db, { today: scenario.due });
+ assert.equal(first.schedule_id, schedule.id);
+ assert.equal(first.ticket.original_due_date, scenario.due);
+
+ completeAt(db, first.ticket, scenario.completed);
+ assert.equal(getSchedule(db, schedule.id).next_due, scenario.next);
+ });
+ }
+});
+
+test('multi-weekday preview advances conditionally and completion enables another same-week chore', (t) => {
+ const db = database(t);
+ const week = '2030-01-06'; // Sunday
+ const schedule = createChore(db, {
+ title: 'Monday Wednesday Friday chore',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [1, 3, 5] },
+ });
+
+ const before = getChoreRoster(db, { week, today: week });
+ assert.deepEqual(
+ before.previews.map(({ schedule_id, due_date, conditional }) => ({
+ schedule_id,
+ due_date,
+ conditional,
+ })),
+ [{ schedule_id: schedule.id, due_date: '2030-01-07', conditional: true }],
+ );
+
+ const [monday] = runSchedules(db, { today: '2030-01-07' });
+ assert.equal(monday.ticket.original_due_date, '2030-01-07');
+ const whileOpen = getChoreRoster(db, { week, today: '2030-01-07' });
+ assert.deepEqual(
+ whileOpen.previews.map(({ schedule_id, due_date, conditional }) => ({
+ schedule_id,
+ due_date,
+ conditional,
+ })),
+ [{ schedule_id: schedule.id, due_date: '2030-01-09', conditional: true }],
+ );
+
+ completeAt(db, monday.ticket, '2030-01-07');
+ const [wednesday] = runSchedules(db, { today: '2030-01-09' });
+ assert.equal(wednesday.schedule_id, schedule.id);
+ assert.equal(wednesday.ticket.original_due_date, '2030-01-09');
+ assert.equal(wednesday.ticket.assignee_id, monday.ticket.assignee_id === 1 ? 2 : 1);
+});
+
+test('manual reassignment changes the next actual owner and roster week load', (t) => {
+ const db = database(t);
+ const week = '2030-01-06'; // Sunday
+ const recurring = createChore(db, {
+ title: 'Reassigned Monday Wednesday chore',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [1, 3] },
+ });
+ createChore(db, {
+ title: 'Second Monday chore',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [1] },
+ });
+
+ const monday = runSchedules(db, { today: '2030-01-07' });
+ assert.equal(monday.length, 2);
+ const first = monday.find(({ schedule_id }) => schedule_id === recurring.id).ticket;
+ const second = monday.find(({ schedule_id }) => schedule_id !== recurring.id).ticket;
+ assert.notEqual(first.assignee_id, second.assignee_id);
+
+ const firstOwner = first.assignee_id;
+ const secondOwner = second.assignee_id;
+ updateTicket(db, first.id, { assignee_id: secondOwner });
+ updateTicket(db, second.id, { assignee_id: firstOwner });
+
+ const reassignedRoster = getChoreRoster(db, { week, today: '2030-01-07' });
+ const counts = new Map([[1, 0], [2, 0]]);
+ for (const assignment of reassignedRoster.assignments)
+ counts.set(assignment.assignee_id, counts.get(assignment.assignee_id) + 1);
+ assert.deepEqual([...counts.values()], [1, 1]);
+ assert.equal(
+ reassignedRoster.assignments.find(({ id }) => id === first.id).assignee_id,
+ secondOwner,
+ );
+
+ completeAt(db, first, '2030-01-07');
+ const wednesday = runSchedules(db, { today: '2030-01-09' }).find(
+ ({ schedule_id }) => schedule_id === recurring.id,
+ );
+ assert.ok(wednesday);
+ assert.equal(
+ wednesday.ticket.assignee_id,
+ firstOwner,
+ 'with equal week loads, the next occurrence avoids the manually selected last owner',
+ );
+});
+
+test('the four prior weeks compensate an otherwise tied chore assignment', (t) => {
+ const db = database(t);
+ const week = '2030-02-03'; // Sunday
+ const history = createChore(db, {
+ title: 'Historical assignments',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [0] },
+ paused: true,
+ });
+
+ for (const date of [
+ '2030-01-06', // exactly four weeks before; the lower edge is included
+ '2030-01-13',
+ '2030-01-20',
+ '2030-01-27',
+ ]) {
+ const ticket = createTicket(
+ db,
+ { title: `Recent work ${date}`, due_date: date },
+ { scheduleId: history.id },
+ );
+ db.prepare(
+ "UPDATE tickets SET assignee_id = 1, original_due_date = ?, status = 'resolved', resolved_at = ? WHERE id = ?",
+ ).run(date, `${date} 12:00:00`, ticket.id);
+ }
+
+ // This fifth-prior-week assignment must not outweigh the four in-window
+ // assignments above.
+ const old = createTicket(
+ db,
+ { title: 'Out-of-window work', due_date: '2029-12-30' },
+ { scheduleId: history.id },
+ );
+ db.prepare(
+ "UPDATE tickets SET assignee_id = 2, original_due_date = '2029-12-30', status = 'resolved', resolved_at = '2029-12-30 12:00:00' WHERE id = ?",
+ ).run(old.id);
+
+ const current = createChore(db, {
+ title: 'Current tie-break chore',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [0] },
+ });
+ const [created] = runSchedules(db, { today: week, onlyChores: true });
+ assert.equal(created.schedule_id, current.id);
+ assert.equal(created.ticket.assignee_id, 2);
+});
+
+test('archiving preserves the open assignment and prevents future occurrences', (t) => {
+ const db = database(t);
+ const week = '2030-06-02'; // Sunday
+ const schedule = createChore(db, {
+ title: 'Keep this open assignment',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [0] },
+ });
+ const [created] = runSchedules(db, { today: week });
+
+ updateSchedule(db, schedule.id, { archived: true });
+ const currentWeek = getChoreRoster(db, { week, today: week });
+ assert.equal(
+ currentWeek.assignments.some(({ id }) => id === created.ticket.id),
+ true,
+ );
+ assert.equal(
+ currentWeek.previews.some(({ schedule_id }) => schedule_id === schedule.id),
+ false,
+ );
+
+ const nextWeek = '2030-06-09';
+ const futureRoster = getChoreRoster(db, { week: nextWeek, today: nextWeek });
+ assert.deepEqual(
+ futureRoster.assignments.map(({ id }) => id),
+ [created.ticket.id],
+ 'the still-open ticket is a carryover, not a new occurrence',
+ );
+ assert.equal(
+ futureRoster.previews.some(({ schedule_id }) => schedule_id === schedule.id),
+ false,
+ );
+ assert.equal(runSchedules(db, { today: nextWeek }).length, 0);
+ assert.equal(
+ db.prepare('SELECT COUNT(*) AS count FROM tickets WHERE schedule_id = ?').get(schedule.id)
+ .count,
+ 1,
+ );
+});
+
+test('ordinary routines and their tickets are absent from the chore roster', (t) => {
+ const db = database(t);
+ const week = '2030-02-03'; // Sunday
+ const ordinary = createSchedule(db, {
+ title: 'Ordinary Monday routine',
+ next_due: '2030-02-04',
+ interval_days: 7,
+ });
+ const chore = createChore(db, {
+ title: 'Chore Monday routine',
+ next_due: week,
+ recurrence: { kind: 'weekly', weekdays: [1] },
+ });
+ const fired = runSchedules(db, { today: '2030-02-04' });
+ assert.equal(fired.length, 2);
+
+ const roster = getChoreRoster(db, { week, today: '2030-02-04' });
+ assert.deepEqual(roster.chores.map(({ id }) => id), [chore.id]);
+ assert.deepEqual(
+ roster.assignments.map(({ schedule_id }) => schedule_id),
+ [chore.id],
+ );
+ assert.equal(roster.assignments.some(({ schedule_id }) => schedule_id === ordinary.id), false);
+});
+
+test('the roster Sunday boundary follows the configured app time zone', (t) => {
+ const db = database(t, 'America/Los_Angeles');
+ const beforeLocalSunday = getChoreRoster(db, {
+ today: new Date('2026-09-20T06:59:00Z'), // Saturday 23:59 in Los Angeles
+ });
+ assert.equal(beforeLocalSunday.today, '2026-09-19');
+ assert.equal(beforeLocalSunday.week_start, '2026-09-13');
+
+ const afterLocalMidnight = getChoreRoster(db, {
+ today: new Date('2026-09-20T07:01:00Z'), // Sunday 00:01 in Los Angeles
+ });
+ assert.equal(afterLocalMidnight.today, '2026-09-20');
+ assert.equal(afterLocalMidnight.week_start, '2026-09-20');
+});
+
+test('chore time zones are fixed to the app zone while ordinary routines keep custom zones', (t) => {
+ const db = database(t, 'America/Los_Angeles');
+ const recurrence = { kind: 'weekly', weekdays: [1] };
+
+ assert.throws(
+ () =>
+ createSchedule(db, {
+ title: 'Mismatched chore',
+ is_chore: true,
+ next_due: '2026-09-21',
+ recurrence,
+ time_zone: 'UTC',
+ }),
+ /Chore time_zone must match the application time zone/,
+ );
+
+ const chore = createSchedule(db, {
+ title: 'App-zone chore',
+ is_chore: true,
+ next_due: '2026-09-21',
+ recurrence,
+ });
+ assert.equal(chore.time_zone, 'America/Los_Angeles');
+ assert.throws(
+ () => updateSchedule(db, chore.id, { time_zone: 'UTC' }),
+ /Chore time_zone must match the application time zone/,
+ );
+ assert.equal(getSchedule(db, chore.id).time_zone, 'America/Los_Angeles');
+
+ const routine = createSchedule(db, {
+ title: 'Custom-zone routine',
+ next_due: '2026-09-21',
+ recurrence,
+ time_zone: 'UTC',
+ });
+ assert.equal(routine.time_zone, 'UTC');
+ assert.equal(
+ updateSchedule(db, routine.id, { time_zone: 'America/New_York' }).time_zone,
+ 'America/New_York',
+ );
+});
+
+test('chore materialization uses the app-zone date at the Sunday boundary', (t) => {
+ const db = database(t, 'America/Los_Angeles');
+ const chore = createSchedule(db, {
+ title: 'Saturday chore',
+ is_chore: true,
+ next_due: '2026-09-19',
+ recurrence: { kind: 'weekly', weekdays: [6] },
+ });
+ assert.equal(chore.time_zone, 'America/Los_Angeles');
+
+ // Simulate a legacy row created before chore time zones were constrained.
+ db.prepare('UPDATE schedules SET time_zone = ? WHERE id = ?').run(
+ 'UTC',
+ chore.id,
+ );
+
+ const RealDate = globalThis.Date;
+ const frozenNow = RealDate.parse('2026-09-20T06:59:00Z'); // Saturday 23:59 in Los Angeles
+ globalThis.Date = class extends RealDate {
+ constructor(...args) {
+ super(...(args.length ? args : [frozenNow]));
+ }
+
+ static now() {
+ return frozenNow;
+ }
+ };
+ try {
+ const [created] = runSchedules(db);
+ assert.equal(created.schedule_id, chore.id);
+ assert.equal(created.ticket.original_due_date, '2026-09-19');
+ } finally {
+ globalThis.Date = RealDate;
+ }
+});
diff --git a/test/planning-http.test.js b/test/planning-http.test.js
index d158a60..9e56a45 100644
--- a/test/planning-http.test.js
+++ b/test/planning-http.test.js
@@ -18,6 +18,7 @@ import { projects, savedViews, checklist } from '../src/api/planning.js';
import {
createSchedule,
getSchedule,
+ getChoreRoster,
runSchedules,
} from '../src/api/schedules.js';
import { addAttachment, getAttachment } from '../src/api/attachments.js';
@@ -97,6 +98,227 @@ test('configured civil time zone drives planning filters and new routines', asyn
assert.equal(JSON.parse(legacy.text).time_zone, 'UTC');
});
+test('chore HTTP integration keeps assignment, roster, and occurrence history coherent', async (t) => {
+ const { db, request } = await server(t);
+ const members = db.prepare('SELECT id FROM household_members ORDER BY id').all();
+ assert.equal(members.length, 2, 'the household has two assignable members');
+ const names = ['Chore Member One', 'Chore Member Two'];
+ for (let i = 0; i < members.length; i++) {
+ const renamed = await request(`/api/chores/members/${members[i].id}`, 'PATCH', {
+ name: names[i],
+ });
+ assert.equal(renamed.status, 200);
+ assert.equal(JSON.parse(renamed.text).name, names[i]);
+ }
+
+ const today = JSON.parse((await request('/api/planning')).text).today;
+ const week = addDays(
+ today,
+ -new Date(`${today}T12:00:00Z`).getUTCDay(),
+ );
+ const created = await request('/api/schedules', 'POST', {
+ title: 'Wash the towels',
+ is_chore: true,
+ recurrence: { kind: 'interval', days: 7 },
+ next_due: today,
+ });
+ assert.equal(created.status, 201);
+ const schedule = JSON.parse(created.text);
+ assert.equal(schedule.is_chore, true);
+
+ const rosterResponse = await request(`/api/chores?week=${week}`);
+ assert.equal(rosterResponse.status, 200);
+ const roster = JSON.parse(rosterResponse.text);
+ assert.deepEqual(
+ roster,
+ JSON.parse(JSON.stringify(getChoreRoster(db, { week }))),
+ );
+
+ let occurrences = db
+ .prepare('SELECT id FROM tickets WHERE schedule_id = ? ORDER BY id')
+ .all(schedule.id);
+ assert.equal(occurrences.length, 1, 'a roster read materializes one occurrence');
+ const firstId = occurrences[0].id;
+ let first = JSON.parse((await request(`/api/tickets/${firstId}`)).text);
+ assert.equal(first.is_chore, true);
+ assert.ok(members.some((member) => member.id === first.assignee_id));
+ assert.equal(
+ first.assignee_name,
+ names[members.findIndex((member) => member.id === first.assignee_id)],
+ );
+
+ const assignedTo = members.find((member) => member.id !== first.assignee_id);
+ const changed = await request(`/api/tickets/${firstId}`, 'PATCH', {
+ assignee_id: assignedTo.id,
+ });
+ assert.equal(changed.status, 200);
+ first = JSON.parse(changed.text);
+ assert.equal(first.assignee_id, assignedTo.id);
+ assert.equal(first.assignee_name, names[members.findIndex((m) => m.id === assignedTo.id)]);
+ const assigneeEvent = first.events
+ .filter((event) => event.kind === 'assignee')
+ .at(-1);
+ assert.deepEqual(
+ [assigneeEvent?.from_value, assigneeEvent?.to_value],
+ [names[members.findIndex((m) => m.id !== assignedTo.id)], first.assignee_name],
+ );
+ const listed = JSON.parse((await request('/api/tickets?status=all')).text);
+ assert.equal(listed.find((ticket) => ticket.id === firstId).assignee_name, first.assignee_name);
+ const reassignedRoster = JSON.parse(
+ (await request(`/api/chores?week=${week}`)).text,
+ );
+ const rosterAssignment = reassignedRoster.assignments.find(
+ (assignment) => assignment.id === firstId,
+ );
+ assert.equal(rosterAssignment.assignee_id, assignedTo.id);
+ assert.equal(rosterAssignment.assignee_name, first.assignee_name);
+
+ assert.equal(
+ (await request('/api/tickets', 'POST', {
+ title: 'Untrusted assignment',
+ assignee_id: members[0].id,
+ })).status,
+ 400,
+ 'public ticket creation cannot claim an assignee',
+ );
+ const ordinary = JSON.parse(
+ (await request('/api/tickets', 'POST', { title: 'Ordinary task' })).text,
+ );
+ assert.equal(
+ (await request(`/api/tickets/${ordinary.id}`, 'PATCH', {
+ assignee_id: members[0].id,
+ })).status,
+ 400,
+ 'ordinary tickets cannot be assigned',
+ );
+ assert.equal(
+ (await request(`/api/tickets/${firstId}`, 'PATCH', { assignee_id: 999999 })).status,
+ 400,
+ 'assignments must reference a household member',
+ );
+
+ assert.equal((await request(`/api/tickets/${firstId}`, 'PATCH', { status: 'closed' })).status, 200);
+ // Reading again cannot rematerialize the same scheduled date from history.
+ await request(`/api/chores?week=${week}`);
+ await request(`/api/chores?week=${week}`);
+ occurrences = db
+ .prepare('SELECT id, status FROM tickets WHERE schedule_id = ? ORDER BY id')
+ .all(schedule.id);
+ assert.equal(occurrences.length, 1, 'the same occurrence is not duplicated');
+
+ // Model a later generated occurrence while keeping this test independent of
+ // which weekday the test happens to run on.
+ const newerTicket = createTicket(
+ db,
+ { title: 'Later towel occurrence', due_date: addDays(today, 1) },
+ { scheduleId: schedule.id },
+ );
+ db.prepare(
+ 'UPDATE tickets SET assignee_id = ?, original_due_date = ? WHERE id = ?',
+ ).run(assignedTo.id, addDays(today, 1), newerTicket.id);
+
+ assert.equal(
+ (await request(`/api/tickets/${firstId}`, 'PATCH', { status: 'open' })).status,
+ 400,
+ 'an older chore cannot reopen ahead of a newer unfinished occurrence',
+ );
+ assert.equal(
+ (await request('/api/tickets/bulk', 'POST', {
+ ids: [firstId, newerTicket.id],
+ status: 'open',
+ })).status,
+ 400,
+ 'bulk reopening uses the same chore guard',
+ );
+ assert.equal(JSON.parse((await request(`/api/tickets/${firstId}`)).text).status, 'closed');
+
+ assert.equal(
+ (await request(`/api/schedules/${schedule.id}`, 'PATCH', { archived: true })).status,
+ 200,
+ );
+ assert.equal(
+ (await request(`/api/tickets/${firstId}`, 'DELETE')).status,
+ 400,
+ 'archiving the schedule does not make generated history deletable',
+ );
+ assert.equal((await request(`/api/tickets/${firstId}`)).status, 200);
+ assert.equal((await request(`/api/tickets/${ordinary.id}`, 'DELETE')).status, 204);
+});
+
+test('chore roster reads only materialize current-week chores; maintenance fires routines', async (t) => {
+ const { db, request } = await server(t);
+ const { today } = JSON.parse((await request('/api/planning')).text);
+ const currentWeek = addDays(
+ today,
+ -new Date(`${today}T12:00:00Z`).getUTCDay(),
+ );
+ const pastWeek = addDays(currentWeek, -7);
+ const invalidWeek = addDays(currentWeek, 1);
+
+ const choreResponse = await request('/api/schedules', 'POST', {
+ title: 'Current-week chore',
+ is_chore: true,
+ recurrence: { kind: 'interval', days: 7 },
+ next_due: today,
+ });
+ assert.equal(choreResponse.status, 201);
+ const choreId = JSON.parse(choreResponse.text).id;
+ const routineResponse = await request('/api/schedules', 'POST', {
+ title: 'Ordinary routine',
+ recurrence: { kind: 'interval', days: 7 },
+ next_due: today,
+ });
+ assert.equal(routineResponse.status, 201);
+ const routineId = JSON.parse(routineResponse.text).id;
+ const occurrences = (scheduleId) =>
+ db
+ .prepare('SELECT id FROM tickets WHERE schedule_id = ? ORDER BY id')
+ .all(scheduleId);
+
+ const pastRosterResponse = await request(`/api/chores?week=${pastWeek}`);
+ assert.equal(pastRosterResponse.status, 200);
+ assert.equal(JSON.parse(pastRosterResponse.text).week_start, pastWeek);
+ assert.deepEqual(occurrences(choreId), [], 'past roster reads do not fire chores');
+ assert.deepEqual(
+ occurrences(routineId),
+ [],
+ 'past roster reads do not fire ordinary routines',
+ );
+
+ assert.equal(
+ (await request(`/api/chores?week=${invalidWeek}`)).status,
+ 400,
+ 'a non-Sunday week is rejected',
+ );
+ assert.deepEqual(occurrences(choreId), [], 'invalid weeks do not fire chores');
+ assert.deepEqual(
+ occurrences(routineId),
+ [],
+ 'invalid weeks do not fire ordinary routines',
+ );
+
+ const currentRosterResponse = await request(`/api/chores?week=${currentWeek}`);
+ assert.equal(currentRosterResponse.status, 200);
+ const currentRoster = JSON.parse(currentRosterResponse.text);
+ assert.equal(currentRoster.week_start, currentWeek);
+ assert.equal(occurrences(choreId).length, 1, 'current roster reads fire due chores');
+ assert.deepEqual(
+ occurrences(routineId),
+ [],
+ 'current roster reads do not fire ordinary routines',
+ );
+
+ const maintenanceResponse = await request('/api/maintenance/run', 'POST');
+ assert.equal(maintenanceResponse.status, 200);
+ const maintenance = JSON.parse(maintenanceResponse.text);
+ const routineFired = maintenance.schedules_fired.find(
+ (entry) => entry.schedule_id === routineId,
+ );
+ assert.ok(routineFired, 'manual maintenance still fires ordinary routines');
+ assert.equal(occurrences(routineId).length, 1);
+ assert.equal(occurrences(routineId)[0].id, routineFired.ticket_id);
+});
+
test('HTTP planning CRUD, order and saved-view filters execute the public contract', async (t) => {
const { request } = await server(t);
let res = await request('/api/projects', 'POST', { name: 'Office' });
diff --git a/test/planning.test.js b/test/planning.test.js
index e52cbdc..dc442b8 100644
--- a/test/planning.test.js
+++ b/test/planning.test.js
@@ -16,12 +16,13 @@ import {
} from '../src/api/planning.js';
import {
createSchedule,
+ getChoreRoster,
getSchedule,
updateSchedule,
runSchedules,
} from '../src/api/schedules.js';
-import { nextOccurrence } from '../src/recurrence.js';
-import { civilDate, addDays } from '../src/dates.js';
+import { nextOccurrence, syncRecurrence } from '../src/recurrence.js';
+import { civilDate, addDays, configureDates } from '../src/dates.js';
import { submitOnce } from '../src/submissions.js';
import { renderCalendar } from '../src/api/calendar.js';
@@ -179,6 +180,46 @@ test('reopening and recompleting a calendar routine does not skip its next occur
assert.equal(getSchedule(db, schedule.id).next_due, due);
});
+test('chore recurrence commits only the next known weekly occurrence after completion', (t) => {
+ const db = database(t);
+ configureDates(db, 'UTC');
+ const today = new Date().toISOString().slice(0, 10);
+ const weekStart = addDays(
+ today,
+ -new Date(`${today}T12:00:00Z`).getUTCDay(),
+ );
+ const schedule = createSchedule(db, {
+ title: 'Wipe counters',
+ is_chore: true,
+ next_due: weekStart,
+ time_zone: 'UTC',
+ recurrence: { kind: 'weekly', weekdays: [0, 1, 2, 3, 4, 5, 6] },
+ });
+ assert.equal(schedule.is_chore, true);
+ assert.equal(schedule.lead_days, 0);
+
+ const first = runSchedules(db, { today: weekStart })[0].ticket;
+ assert.equal(first.original_due_date, weekStart);
+ assert.equal(
+ getChoreRoster(db, { week: weekStart, today: weekStart }).previews[0]
+ .conditional,
+ true,
+ );
+
+ // Simulate a completion on the preceding day so its successor is known for
+ // today. Calendar recurrences never create the intervening missed dates.
+ db.prepare(
+ "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?",
+ ).run(`${addDays(today, -1)} 12:00:00`, first.id);
+ syncRecurrence(db, first.id);
+ assert.equal(getSchedule(db, schedule.id).next_due, today);
+
+ const successor = runSchedules(db, { today });
+ assert.equal(successor.length, 1);
+ assert.equal(successor[0].ticket.original_due_date, today);
+ assert.equal(successor[0].ticket.schedule_id, schedule.id);
+});
+
test('reopening older history cannot generate a parallel unfinished recurrence', (t) => {
const db = database(t),
today = civilDate();
diff --git a/test/queues.test.js b/test/queues.test.js
index 3045e9a..faa6251 100644
--- a/test/queues.test.js
+++ b/test/queues.test.js
@@ -140,6 +140,7 @@ test('migration preserves version-5 records and attachment bytes; backup restore
const scratch = await mkdtemp(join(tmpdir(), 'task-hub-migration-'));
let db;
let restored;
+ let primaryError;
try {
const path = join(scratch, 'legacy.db');
db = openDatabase(path);
@@ -149,7 +150,15 @@ test('migration preserves version-5 records and attachment bytes; backup restore
const { queue: _queue, ...before } = getTicket(db, task.id);
// Reconstruct the immediately preceding schema, then exercise the normal
// open/migrate path against its on-disk records (not an empty database).
- db.exec(`DROP TABLE submissions; DROP TABLE saved_views; DROP TABLE checklist_items;
+ db.exec(`DROP INDEX idx_tickets_assignee;
+ DROP INDEX idx_tickets_original_due_date;
+ ALTER TABLE tickets DROP COLUMN assignee_id;
+ ALTER TABLE tickets DROP COLUMN original_due_date;
+ DROP TABLE household_members;
+ ALTER TABLE schedules DROP COLUMN is_chore;
+ ALTER TABLE schedules DROP COLUMN archived;
+ ALTER TABLE schedules DROP COLUMN last_due;
+ DROP TABLE submissions; DROP TABLE saved_views; DROP TABLE checklist_items;
DROP INDEX idx_tickets_project;
ALTER TABLE tickets DROP COLUMN project_id;
ALTER TABLE tickets DROP COLUMN today_rank;
@@ -161,9 +170,10 @@ test('migration preserves version-5 records and attachment bytes; backup restore
ALTER TABLE schedules DROP COLUMN project_id;
ALTER TABLE schedules DROP COLUMN checklist;
ALTER TABLE schedules DROP COLUMN time_zone;
- ALTER TABLE schedules DROP COLUMN last_due;
DROP TABLE projects;
- DROP INDEX idx_tickets_queue_status; ALTER TABLE tickets DROP COLUMN queue; PRAGMA user_version = 5`);
+ DROP INDEX idx_tickets_queue_status;
+ ALTER TABLE tickets DROP COLUMN queue;
+ PRAGMA user_version = 5`);
db.close();
db = null;
db = openDatabase(path);
@@ -178,9 +188,26 @@ test('migration preserves version-5 records and attachment bytes; backup restore
db.close();
db = openDatabase(path);
assert.equal(getTicket(db, task.id).queue, 'someday');
+ } catch (error) {
+ primaryError = error;
+ throw error;
} finally {
- restored?.close();
- db?.close();
- await rm(scratch, { recursive: true, force: true });
+ let cleanupError;
+ for (const handle of [restored, db]) {
+ try {
+ handle?.close();
+ } catch (error) {
+ cleanupError ??= error;
+ }
+ }
+ try {
+ await rm(scratch, { recursive: true, force: true });
+ } catch (error) {
+ cleanupError ??= error;
+ }
+ if (cleanupError) {
+ if (primaryError) primaryError.cleanupError = cleanupError;
+ else throw cleanupError;
+ }
}
});
diff --git a/test/schedules.test.js b/test/schedules.test.js
index 7d2f5cd..87dca15 100644
--- a/test/schedules.test.js
+++ b/test/schedules.test.js
@@ -2,15 +2,19 @@ import test, { beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { openDatabase, transaction } from '../src/db.js';
import { createDevice } from '../src/api/devices.js';
-import { getTicket, listTickets } from '../src/api/tickets.js';
+import { createTicket, getTicket, listTickets } from '../src/api/tickets.js';
import {
listSchedules,
getSchedule,
+ getChoreRoster,
+ updateHouseholdMember,
createSchedule,
updateSchedule,
deleteSchedule,
runSchedules,
} from '../src/api/schedules.js';
+import { syncRecurrence } from '../src/recurrence.js';
+import { addDays } from '../src/dates.js';
let db;
@@ -26,6 +30,16 @@ const today = () => db.prepare(`SELECT date('now') AS d`).get().d;
const daysFromNow = (n) =>
db.prepare(`SELECT date('now', ?) AS d`).get(`${n >= 0 ? '+' : ''}${n} days`).d;
+const createChore = (title, next_due, weekdays = [0]) =>
+ createSchedule(db, {
+ title,
+ next_due,
+ interval_days: 7,
+ recurrence: { kind: 'weekly', weekdays },
+ is_chore: true,
+ });
+const createTicketForChoreTest = (title) => createTicket(db, { title });
+
/* ---- Creating ----------------------------------------------------------- */
test('a new schedule defaults to being due today', () => {
@@ -77,6 +91,259 @@ test('rejects a device that does not exist', () => {
);
});
+test('chore opt-in is explicit and requires a modern recurrence', () => {
+ const routine = createSchedule(db, { title: 'Routine', interval_days: 7 });
+ assert.equal(routine.is_chore, false);
+ assert.equal(routine.archived, false);
+ assert.throws(
+ () => createSchedule(db, { title: 'No cadence', is_chore: true }),
+ /Chore schedules require a modern recurrence/,
+ );
+ assert.deepEqual(listSchedules(db, { is_chore: true }), []);
+ assert.equal(listSchedules(db, { is_chore: false }).length, 1);
+ const chore = createChore('Opted in at creation', '2026-09-20', [0]);
+ assert.throws(
+ () => updateSchedule(db, chore.id, { recurrence: null }),
+ /Chore schedules require a modern recurrence/,
+ );
+ assert.ok(getSchedule(db, chore.id).recurrence);
+ assert.throws(
+ () => updateSchedule(db, chore.id, { is_chore: false }),
+ /is_chore cannot be changed/,
+ );
+ assert.throws(
+ () =>
+ updateSchedule(db, routine.id, {
+ is_chore: true,
+ recurrence: { kind: 'weekly', weekdays: [2] },
+ }),
+ /is_chore cannot be changed/,
+ );
+ assert.equal(updateSchedule(db, chore.id, { archived: true }).archived, true);
+ assert.throws(
+ () => updateSchedule(db, chore.id, { archived: false }),
+ /cannot be unarchived/,
+ );
+ assert.equal(updateSchedule(db, chore.id, { title: 'Archived title' }).archived, true);
+ assert.equal(listSchedules(db, { is_chore: true, archived: true }).length, 1);
+});
+
+test('the persistent roster has two stable editable member IDs', () => {
+ const initial = getChoreRoster(db, { today: '2026-09-25' });
+ assert.deepEqual(initial.members, [
+ { id: 1, name: 'Member 1' },
+ { id: 2, name: 'Member 2' },
+ ]);
+ assert.deepEqual(updateHouseholdMember(db, 1, { name: 'Alex' }), {
+ id: 1,
+ name: 'Alex',
+ });
+ assert.equal(getChoreRoster(db, { today: '2026-09-25' }).members[0].id, 1);
+ assert.throws(() => updateHouseholdMember(db, 1, { name: 'Member 2' }), /unique/);
+});
+
+test('chores cannot be converted from an existing ticket', () => {
+ const task = createTicketForChoreTest('Existing task');
+ assert.throws(
+ () =>
+ createSchedule(db, {
+ title: 'Chore',
+ is_chore: true,
+ next_due: '2026-09-20',
+ recurrence: { kind: 'weekly', weekdays: [0] },
+ source_ticket_id: task.id,
+ }),
+ /cannot be created from an existing ticket/,
+ );
+ assert.equal(getTicket(db, task.id).schedule_id, null);
+});
+
+test('runSchedules can materialize chores without firing ordinary routines', () => {
+ const routine = createSchedule(db, {
+ title: 'Ordinary routine',
+ next_due: '2026-09-20',
+ interval_days: 7,
+ });
+ const chore = createChore('Household chore', '2026-09-20', [0]);
+
+ const choresOnly = runSchedules(db, {
+ today: '2026-09-20',
+ onlyChores: true,
+ });
+ assert.deepEqual(
+ choresOnly.map(({ schedule_id }) => schedule_id),
+ [chore.id],
+ );
+ assert.equal(getSchedule(db, routine.id).last_ticket_id, null);
+ assert.equal(listTickets(db).length, 1);
+
+ const normalSweep = runSchedules(db, { today: '2026-09-20' });
+ assert.deepEqual(
+ normalSweep.map(({ schedule_id }) => schedule_id),
+ [routine.id],
+ );
+});
+
+test('Sunday commits known chores, balances assignment, and exposes later work conditionally', () => {
+ const actualToday = today();
+ const weekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay();
+ const weekStart = addDays(actualToday, -weekday);
+ const daily = createChore('Daily dishes', weekStart, [0, 1, 2, 3, 4, 5, 6]);
+ const weekly = createChore('Weekly floors', weekStart, [0]);
+
+ const fired = runSchedules(db, { today: weekStart });
+ assert.deepEqual(
+ fired.map(({ ticket }) => ticket.assignee_id),
+ [1, 2],
+ 'the initial load is split between the two members',
+ );
+ assert.ok(fired.every(({ ticket }) => ticket.original_due_date === weekStart));
+ assert.ok(fired.every(({ ticket }) => ticket.events.some((e) => e.kind === 'assignee')));
+ assert.equal(runSchedules(db, { today: addDays(weekStart, 1) }).length, 0);
+
+ const roster = getChoreRoster(db, { week: weekStart, today: weekStart });
+ assert.equal(roster.week_start, weekStart);
+ assert.equal(roster.week_end, addDays(weekStart, 6));
+ assert.deepEqual(
+ roster.assignments.map(({ assignee_id }) => assignee_id),
+ [1, 2],
+ );
+ assert.deepEqual(roster.previews, [
+ {
+ schedule_id: daily.id,
+ title: 'Daily dishes',
+ due_date: addDays(weekStart, 1),
+ conditional: true,
+ },
+ ]);
+ assert.equal(roster.chores.find((chore) => chore.id === weekly.id).is_chore, true);
+});
+
+test('completion enables a later weekly occurrence without backfilling a missed one', () => {
+ const actualToday = today();
+ const actualWeekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay();
+ const weekStart = addDays(actualToday, -actualWeekday);
+ const schedule = createChore(
+ 'Daily counter',
+ weekStart,
+ [0, 1, 2, 3, 4, 5, 6],
+ );
+ const first = runSchedules(db, { today: weekStart })[0].ticket;
+
+ // Complete on the day before the injected current day; the successor is
+ // therefore a known current-week occurrence, not historical catch-up.
+ db.prepare(
+ "UPDATE tickets SET status = 'resolved', resolved_at = ? WHERE id = ?",
+ ).run(`${addDays(actualToday, -1)} 12:00:00`, first.id);
+ syncRecurrence(db, first.id);
+ assert.equal(getSchedule(db, schedule.id).next_due, actualToday);
+
+ const next = runSchedules(db, { today: actualToday });
+ assert.equal(next.length, 1);
+ assert.equal(next[0].ticket.original_due_date, actualToday);
+ assert.notEqual(next[0].ticket.assignee_id, first.assignee_id);
+
+ // A Monday missed while offline is skipped to the next occurrence in this
+ // week, rather than being materialized with a historical due date.
+ const midweek = createChore('Midweek laundry', '2026-09-21', [1, 4]);
+ const missed = runSchedules(db, { today: '2026-09-22' });
+ const laundry = missed.find(({ schedule_id }) => schedule_id === midweek.id);
+ assert.equal(laundry.ticket.original_due_date, '2026-09-24');
+});
+
+test('roster browsing is read-only, validates Sunday starts, and only creates within this week', () => {
+ const schedule = createChore('Future chore', '2026-09-27', [0]);
+ const before = getSchedule(db, schedule.id).next_due;
+ assert.throws(
+ () => getChoreRoster(db, { week: '2026-09-21', today: '2026-09-25' }),
+ /week must be a Sunday/,
+ );
+ const past = getChoreRoster(db, { week: '2026-09-20', today: '2026-09-25' });
+ assert.equal(past.assignments.length, 0);
+ assert.equal(getSchedule(db, schedule.id).next_due, before);
+ assert.equal(runSchedules(db, { today: '2026-09-25' }).length, 0);
+ assert.equal(listTickets(db).length, 0);
+});
+
+test('deleting a chore archives it and retains its open assignment history', () => {
+ const schedule = createChore('Keep the entry clear', '2026-09-20', [0]);
+ const [{ ticket }] = runSchedules(db, { today: '2026-09-20' });
+
+ deleteSchedule(db, schedule.id);
+
+ assert.equal(getSchedule(db, schedule.id).archived, true);
+ assert.equal(getTicket(db, ticket.id).assignee_id, 1);
+ assert.equal(getChoreRoster(db, { week: '2026-09-20', today: '2026-09-20' }).assignments.length, 1);
+ assert.equal(runSchedules(db, { today: '2026-09-27' }).length, 0);
+ assert.equal(listSchedules(db, { archived: true }).length, 1);
+});
+
+test('an overdue open chore retains its original owner and date as a carryover', () => {
+ const prior = createChore('Old chore', '2026-09-13', [0]);
+ const [{ ticket: overdue }] = runSchedules(db, { today: '2026-09-13' });
+ const next = createChore('New week chore', '2026-09-20', [0]);
+
+ const created = runSchedules(db, { today: '2026-09-20' });
+ const current = created.find(({ schedule_id }) => schedule_id === next.id).ticket;
+ db.prepare('UPDATE tickets SET due_date = ? WHERE id = ?').run(
+ '2026-09-28',
+ overdue.id,
+ );
+ const roster = getChoreRoster(db, {
+ week: '2026-09-20',
+ today: '2026-09-20',
+ });
+ const carried = roster.assignments.find(({ id }) => id === overdue.id);
+ assert.equal(carried.assignee_id, overdue.assignee_id);
+ assert.equal(carried.original_due_date, '2026-09-13');
+ assert.equal(carried.due_date, '2026-09-28');
+ assert.notEqual(
+ current.assignee_id,
+ overdue.assignee_id,
+ 'the carryover shifts new work to the less-loaded member',
+ );
+ assert.ok(roster.chores.some(({ id }) => id === prior.id));
+});
+
+test('historical chore starts advance to the current week without backfilling', () => {
+ const actualToday = today();
+ const weekday = new Date(`${actualToday}T12:00:00Z`).getUTCDay();
+ const oldStart = addDays(actualToday, -45);
+ const schedule = createChore('Historical start', oldStart, [weekday]);
+
+ const [{ ticket }] = runSchedules(db, { today: actualToday });
+ assert.equal(ticket.schedule_id, schedule.id);
+ assert.equal(ticket.due_date, actualToday);
+ assert.equal(ticket.original_due_date, actualToday);
+});
+
+test('fair final tie-break varies by schedule rather than favoring the lower member ID', () => {
+ const firstWeek = '2026-01-04';
+ const firstSchedule = createChore('First fair chore', firstWeek, [0]);
+ const [first] = runSchedules(db, { today: firstWeek });
+ db.prepare(
+ "UPDATE tickets SET status = 'resolved', resolved_at = '2026-01-04 12:00:00' WHERE id = ?",
+ ).run(first.ticket.id);
+ deleteSchedule(db, firstSchedule.id);
+
+ const secondWeek = '2026-02-15';
+ const secondSchedule = createChore('Second fair chore', secondWeek, [0]);
+ const laterFired = runSchedules(db, { today: secondWeek });
+ assert.deepEqual(
+ laterFired.map(({ schedule_id }) => schedule_id),
+ [secondSchedule.id],
+ 'the completed archived recurrence is not generated again',
+ );
+ const [second] = laterFired;
+
+ assert.notEqual(
+ first.ticket.assignee_id,
+ second.ticket.assignee_id,
+ 'with no current or prior-four-week load and no schedule-specific prior owner, the tie shifts',
+ );
+ assert.equal(second.schedule_id, secondSchedule.id);
+});
+
/* ---- Firing ------------------------------------------------------------- */
test('fires a due schedule and advances it by one interval', () => {